From 098ac7f66a12b8e4aa8b8f0b2af1e492ae09dbc3 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 6 Aug 2026 08:51:03 -0400 Subject: [PATCH 1/7] [ty] Validate augmented assignment stores --- .../resources/mdtest/assignment/augmented.md | 279 ++++++++++++++++++ .../src/types/infer/builder.rs | 239 +++++++++++++-- .../src/types/infer/builder/subscript.rs | 20 ++ 3 files changed, 520 insertions(+), 18 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md b/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md index 1787834ee654e3..6717921f137a8d 100644 --- a/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md +++ b/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md @@ -187,6 +187,285 @@ def f(flag: bool, flag2: bool): reveal_type(f) # revealed: float | str ``` +## Annotated name targets + +An augmented assignment to an annotated name must validate its result against the declaration. An +unannotated name can instead change type. + +```py +class Value: + def __add__(self, other: int) -> object: + return other + +annotated: Value = Value() +# error: [invalid-assignment] +annotated += 1 +reveal_type(annotated) # revealed: Value + +inferred = Value() +inferred += 1 +reveal_type(inferred) # revealed: object +``` + +## Attribute targets + +The result must satisfy the attribute's write contract, whether the operation uses `__iadd__` or +falls back to `__add__`. + +```py +class AddValue: + def __add__(self, other: int) -> object: + return other + +class InplaceValue: + def __iadd__(self, other: int) -> object: + return other + +class Holder: + add: AddValue + inplace: InplaceValue + +holder = Holder() +# error: [invalid-assignment] +holder.add += 1 +reveal_type(holder.add) # revealed: AddValue + +# error: [invalid-assignment] +holder.inplace += 1 +reveal_type(holder.inplace) # revealed: InplaceValue +``` + +## Attribute descriptors + +Even an in-place operation writes its result back, so a read-only property rejects augmented +assignment. + +```py +class ReadOnly: + @property + def value(self) -> int: + return 1 + +read_only = ReadOnly() +# error: [invalid-assignment] +read_only.value += 1 +reveal_type(read_only.value) # revealed: int +``` + +A property's setter can accept a different type than its getter returns. The operation's result must +satisfy the setter, while later reads continue to use the getter type. + +```py +class ReadValue: + def __iadd__(self, other: int) -> str: + return "updated" + +class Writable: + @property + def value(self) -> ReadValue: + return ReadValue() + + @value.setter + def value(self, value: str) -> None: + pass + +writable = Writable() +writable.value += 1 +reveal_type(writable.value) # revealed: ReadValue +``` + +## Subscript targets + +An augmented subscript assignment must pass its result, not the operator's right-hand operand, to +the target's `__setitem__` method. + +```py +class Value: + def __iadd__(self, other: int) -> object: + return other + +class Container: + def __getitem__(self, key: int) -> Value: + return Value() + + def __setitem__(self, key: int, value: Value) -> None: + pass + +container = Container() +# error: [invalid-assignment] +container[0] += 1 +reveal_type(container[0]) # revealed: Value +``` + +Explicitly annotated lists and dictionaries retain their write contracts, including through one-hop +aliases and references from enclosing scopes. + +```py +items: list[Value] = [Value()] +# error: [invalid-assignment] +items[0] += 1 +reveal_type(items[0]) # revealed: Value + +alias = items +# error: [invalid-assignment] +alias[0] += 1 + +separately_declared: list[Value] +separately_declared = [Value()] +# error: [invalid-assignment] +separately_declared[0] += 1 + +mapping: dict[str, Value] = {"value": Value()} +# error: [invalid-assignment] +mapping["value"] += 1 +reveal_type(mapping["value"]) # revealed: Value + +def update_declared_outer() -> None: + # error: [invalid-assignment] + items[0] += 1 + + alias = items + # error: [invalid-assignment] + alias[0] += 1 +``` + +Declared collection-valued attributes also retain their write contracts through immediate aliases. + +```py +class Holder: + values: list[Value] + +holder = Holder() +# error: [invalid-assignment] +holder.values[0] += 1 + +member_alias = holder.values +# error: [invalid-assignment] +member_alias[0] += 1 +``` + +Typed dictionary entries validate the value written back to their declared fields. + +```py +from typing import TypedDict + +class Payload(TypedDict): + value: Value + +payload: Payload = {"value": Value()} +# error: [invalid-assignment] +payload["value"] += 1 +reveal_type(payload["value"]) # revealed: Value +``` + +## Read-only subscripts + +A readable subscript is not necessarily writable. + +```py +values: tuple[int] = (1,) +# error: [invalid-assignment] +values[0] += 1 +``` + +## Failed attribute and subscript loads + +If the load has already failed, its corresponding store must not emit another diagnostic. + +```py +class Missing: ... + +missing = Missing() +# error: [unresolved-attribute] +missing.value += 1 + +mapping: dict[str, int] = {} +# error: [invalid-argument-type] +mapping[1] += 1 +``` + +## Failed augmented operations + +An operation that cannot run does not perform a store. + +```py +class Value: + def __iadd__(self, other: int) -> object: + return other + +class Holder: + value: Value + +holder = Holder() +# error: [unsupported-operator] +holder.value += "invalid" +``` + +## Correlated union targets + +The result of an operation on one union member must not be checked against another member's write +contract. + +```py +class AValue: + def __iadd__(self, other: int) -> "AValue": + return self + +class BValue: + def __iadd__(self, other: int) -> "BValue": + return self + +class A: + value: AValue + +class B: + value: BValue + +def update(value: A | B) -> None: + value.value += 1 +``` + +## Inferred collection targets + +Augmented stores to inferred collection literals must not be treated as writes to an explicitly +declared element type. + +```py +values = [1] +values[0] /= 2 + +alias = values +alias[0] /= 2 + +second_alias = alias +second_alias[0] /= 2 + +nested = [[1]] +nested[0][0] /= 2 +``` + +The same inference behavior applies to unannotated attributes and values from enclosing scopes. + +```py +class Holder: + def __init__(self) -> None: + self.values = [1] + + def update(self) -> None: + self.values[0] /= 2 + + alias = self.values + alias[0] /= 2 + +outer = [1] + +def update_outer() -> None: + outer[0] /= 2 + + alias = outer + alias[0] /= 2 +``` + ## Implicit dunder calls on class objects ```py diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index b8c5448e318ce2..5dd1857f330f8f 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -4635,18 +4635,33 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if let ast::Expr::Attribute(attr_expr) = assignment.target.as_ref() { let object_ty = self.expression_type(&attr_expr.value); self.report_undeclared_protocol_attribute(attr_expr); - self.validate_final_attribute_assignment(attr_expr, object_ty, attr_expr.attr.id()); + + // Composite receiver stores are outside the scope of augmented-store validation, + // but their existing `Final` checks must continue to apply. + if object_ty.as_union_like(self.db()).is_some() + || matches!( + object_ty.resolve_type_alias(self.db()), + Type::Intersection(_) + ) + { + self.validate_final_attribute_assignment( + attr_expr, + object_ty, + attr_expr.attr.id(), + ); + } } } } + /// Infer an augmented operator, returning its recovery type if the operation fails. fn infer_augmented_op( &mut self, assignment: &ast::StmtAugAssign, target_type: Type<'db>, value_expr: &ast::Expr, infer_value_ty: &mut dyn FnMut(&mut Self, TypeContext<'db>) -> Type<'db>, - ) -> Type<'db> { + ) -> Result, Type<'db>> { let db = self.db(); let env = self.program_environment(); // If the target defines, e.g., `__iadd__`, infer the augmented assignment as a call to that @@ -4657,7 +4672,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let binary_return_ty = |builder: &mut Self, value_ty| { builder .infer_binary_expression_type(assignment.into(), false, target_type, value_ty, op) - .unwrap_or_else(|| { + .ok_or_else(|| { report_unsupported_augmented_assignment( &builder.context, assignment, @@ -4676,14 +4691,27 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // equally applicable type contexts for each union member. infer_value_ty.infer_loud(self, TypeContext::default()); - union.map(db, env, |&elem_type| { - self.infer_augmented_op( + let mut operation_failed = false; + let result_ty = union.map(db, env, |&elem_type| { + match self.infer_augmented_op( assignment, elem_type, value_expr, &mut |builder, tcx| infer_value_ty.infer_silent(builder, tcx), - ) - }) + ) { + Ok(ty) => ty, + Err(recovery_ty) => { + operation_failed = true; + recovery_ty + } + } + }); + + if operation_failed { + Err(result_ty) + } else { + Ok(result_ty) + } } _ => { @@ -4695,7 +4723,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { infer_value_ty, ) { - return typed_dict_update_ty; + return Ok(typed_dict_update_ty); } let ast_arguments = [ArgOrKeyword::Arg(value_expr)]; @@ -4711,7 +4739,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { TypeContext::default(), ); match call { - Ok(outcome) => outcome.return_type(db, env), + Ok(outcome) => Ok(outcome.return_type(db, env)), Err(CallDunderError::MethodNotAvailable) => { let value_ty = infer_value_ty(self, TypeContext::default()); binary_return_ty(self, value_ty) @@ -4720,12 +4748,20 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { bindings: outcome, .. }) => { let value_ty = outcome.type_for_argument(&call_arguments, 0); - UnionType::from_two_elements( - db, - env, - outcome.return_type(db, env), - binary_return_ty(self, value_ty), - ) + match binary_return_ty(self, value_ty) { + Ok(binary_ty) => Ok(UnionType::from_two_elements( + db, + env, + outcome.return_type(db, env), + binary_ty, + )), + Err(recovery_ty) => Err(UnionType::from_two_elements( + db, + env, + outcome.return_type(db, env), + recovery_ty, + )), + } } Err(CallDunderError::CallError(_, bindings, _)) => { let value_ty = bindings.type_for_argument(&call_arguments, 0); @@ -4735,7 +4771,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { target_type, value_ty, ); - bindings.return_type(db, env) + Err(bindings.return_type(db, env)) } } } @@ -4781,8 +4817,175 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { _ => self.infer_expression(target, TypeContext::default()), }; - self.infer_augmented_op(assignment, target_type, value, &mut |builder, tcx| { - builder.infer_expression(value, tcx) + let result_ty = + match self.infer_augmented_op(assignment, target_type, value, &mut |builder, tcx| { + builder.infer_expression(value, tcx) + }) { + Ok(result_ty) => result_ty, + Err(recovery_ty) => return recovery_ty, + }; + + let db = self.db(); + let env = self.program_environment(); + match target.as_ref() { + ast::Expr::Attribute(attribute) => { + let object_ty = self.expression_type(&attribute.value); + + // A union or intersection requires correlating each receiver with its own + // operator result; validating the combined result would reject valid programs. + if object_ty.as_union_like(db).is_some() + || matches!(object_ty.resolve_type_alias(db), Type::Intersection(_)) + { + return result_ty; + } + + let PlaceAndQualifiers { + place: + Place::Defined(DefinedPlace { + origin, + definedness: Definedness::AlwaysDefined, + .. + }), + qualifiers, + } = object_ty.member(db, env, &attribute.attr.id) + else { + return result_ty; + }; + + let is_data_descriptor = + assignment_attribute_members(db, env, object_ty, &attribute.attr.id) + .and_then(AssignmentAttributeMembers::type_member) + .and_then(|member| member.place.ignore_possibly_undefined()) + .is_some_and(|ty| ty.may_be_data_descriptor(db, env)); + + // Inferred-only attributes can change type across augmented assignments. Concrete + // descriptors and qualified attributes still impose independent write contracts. + if !origin.is_declared() + && !qualifiers.intersects( + TypeQualifiers::FINAL + | TypeQualifiers::CLASS_VAR + | TypeQualifiers::READ_ONLY, + ) + && !is_data_descriptor + { + return result_ty; + } + + let valid = self.validate_attribute_assignment( + attribute, + target, + object_ty, + attribute.attr.id(), + &mut |_, _| result_ty, + true, + ); + + if !valid || is_data_descriptor { + target_type + } else { + result_ty + } + } + ast::Expr::Subscript(subscript) => { + let object_ty = self.expression_type(&subscript.value); + let slice_ty = self.expression_type(&subscript.slice); + + if object_ty.as_union_like(db).is_some() + || slice_ty.as_union_like(db).is_some() + || matches!(object_ty.resolve_type_alias(db), Type::Intersection(_)) + || object_ty + .subscript(db, env, slice_ty, ExprContext::Load) + .is_err() + || self.is_unannotated_mutable_collection(&subscript.value, object_ty) + { + return result_ty; + } + + if self.validate_augmented_subscript_assignment( + subscript, target, object_ty, slice_ty, result_ty, + ) && (object_ty.is_typed_dict() + || AddBinding::is_safe_mutable_class(db, env, object_ty)) + { + result_ty + } else { + target_type + } + } + _ => result_ty, + } + } + + /// Return whether this collection lacks a directly declared element-type contract. + /// + /// Augmented stores must participate in full-scope collection inference before they can + /// constrain these collections without rejecting valid element-type changes. + fn is_unannotated_mutable_collection(&self, object: &ast::Expr, object_ty: Type<'db>) -> bool { + let db = self.db(); + if !AddBinding::is_safe_mutable_class(db, self.program_environment(), object_ty) { + return false; + } + + let has_declared_type = |expression: &ast::Expr| match expression { + ast::Expr::Name(name) => { + let place = PlaceExpr::from_expr_name(name); + if matches!( + self.infer_place_load(PlaceExprRef::from(&place), ast::ExprRef::Name(name)) + .0 + .place, + Place::Defined(DefinedPlace { + origin: TypeOrigin::Declared, + .. + }) + ) { + return true; + } + + let use_id = ast::ExprRef::Name(name).scoped_use_id(db, self.program_file()); + let use_def = self.index.use_def_map(self.scope.file_scope_id(db)); + use_def.bindings_at_use(use_id).any(|binding| { + binding.binding.definition().is_some_and(|definition| { + matches!(definition.kind(db), DefinitionKind::AnnotatedAssignment(_)) + || use_def + .declarations_at_binding(definition) + .any(|declaration| declaration.declaration.definition().is_some()) + }) + }) + } + ast::Expr::Attribute(attribute) => { + let receiver_ty = self.expression_type(&attribute.value); + matches!( + receiver_ty + .member(db, self.program_environment(), &attribute.attr.id) + .place, + Place::Defined(DefinedPlace { + origin: TypeOrigin::Declared, + .. + }) + ) + } + _ => false, + }; + + if has_declared_type(object) { + return false; + } + + let Some(name) = object.as_name_expr() else { + return true; + }; + let use_id = ast::ExprRef::Name(name).scoped_use_id(db, self.program_file()); + let use_def = self.index.use_def_map(self.scope.file_scope_id(db)); + + !use_def.bindings_at_use(use_id).any(|binding| { + binding + .binding + .definition() + .is_some_and(|definition| match definition.kind(db) { + DefinitionKind::Assignment(assignment) => { + has_declared_type(assignment.value(self.module())) + } + _ => false, + }) }) } diff --git a/crates/ty_python_semantic/src/types/infer/builder/subscript.rs b/crates/ty_python_semantic/src/types/infer/builder/subscript.rs index d5f9680793d18d..58d21e98fb8a23 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/subscript.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/subscript.rs @@ -1662,6 +1662,26 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { is_valid_assignment } + /// Validate the already-inferred value written by an augmented subscript assignment. + pub(super) fn validate_augmented_subscript_assignment( + &mut self, + target: &ast::ExprSubscript, + result_node: &ast::Expr, + object_ty: Type<'db>, + slice_ty: Type<'db>, + result_ty: Type<'db>, + ) -> bool { + self.validate_subscript_assignment_impl( + target, + None, + object_ty, + &mut |_, _| slice_ty, + result_node, + &mut |_, _| result_ty, + true, + ) + } + #[expect(clippy::too_many_arguments)] fn validate_subscript_assignment_impl( &mut self, From e92480b2b4b2114c34b1606f21f6f93a577e006b Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 6 Aug 2026 08:55:25 -0400 Subject: [PATCH 2/7] [ty] Remove inferred collection assignment guard --- .../resources/mdtest/assignment/augmented.md | 62 ++------------- .../src/types/infer/builder.rs | 75 ------------------- 2 files changed, 5 insertions(+), 132 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md b/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md index 6717921f137a8d..61e8b5b4888510 100644 --- a/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md +++ b/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md @@ -297,8 +297,7 @@ container[0] += 1 reveal_type(container[0]) # revealed: Value ``` -Explicitly annotated lists and dictionaries retain their write contracts, including through one-hop -aliases and references from enclosing scopes. +Explicitly annotated lists and dictionaries retain their write contracts. ```py items: list[Value] = [Value()] @@ -306,30 +305,13 @@ items: list[Value] = [Value()] items[0] += 1 reveal_type(items[0]) # revealed: Value -alias = items -# error: [invalid-assignment] -alias[0] += 1 - -separately_declared: list[Value] -separately_declared = [Value()] -# error: [invalid-assignment] -separately_declared[0] += 1 - mapping: dict[str, Value] = {"value": Value()} # error: [invalid-assignment] mapping["value"] += 1 reveal_type(mapping["value"]) # revealed: Value - -def update_declared_outer() -> None: - # error: [invalid-assignment] - items[0] += 1 - - alias = items - # error: [invalid-assignment] - alias[0] += 1 ``` -Declared collection-valued attributes also retain their write contracts through immediate aliases. +Declared collection-valued attributes also retain their write contracts. ```py class Holder: @@ -338,10 +320,6 @@ class Holder: holder = Holder() # error: [invalid-assignment] holder.values[0] += 1 - -member_alias = holder.values -# error: [invalid-assignment] -member_alias[0] += 1 ``` Typed dictionary entries validate the value written back to their declared fields. @@ -427,43 +405,13 @@ def update(value: A | B) -> None: ## Inferred collection targets -Augmented stores to inferred collection literals must not be treated as writes to an explicitly -declared element type. +Augmented assignments do not yet participate in full-scope collection inference. ```py values = [1] +# TODO: This should widen the inferred element type without reporting an error. +# error: [invalid-assignment] values[0] /= 2 - -alias = values -alias[0] /= 2 - -second_alias = alias -second_alias[0] /= 2 - -nested = [[1]] -nested[0][0] /= 2 -``` - -The same inference behavior applies to unannotated attributes and values from enclosing scopes. - -```py -class Holder: - def __init__(self) -> None: - self.values = [1] - - def update(self) -> None: - self.values[0] /= 2 - - alias = self.values - alias[0] /= 2 - -outer = [1] - -def update_outer() -> None: - outer[0] /= 2 - - alias = outer - alias[0] /= 2 ``` ## Implicit dunder calls on class objects diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 5dd1857f330f8f..2caf2d91cc25dc 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -4896,7 +4896,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { || object_ty .subscript(db, env, slice_ty, ExprContext::Load) .is_err() - || self.is_unannotated_mutable_collection(&subscript.value, object_ty) { return result_ty; } @@ -4915,80 +4914,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } - /// Return whether this collection lacks a directly declared element-type contract. - /// - /// Augmented stores must participate in full-scope collection inference before they can - /// constrain these collections without rejecting valid element-type changes. - fn is_unannotated_mutable_collection(&self, object: &ast::Expr, object_ty: Type<'db>) -> bool { - let db = self.db(); - if !AddBinding::is_safe_mutable_class(db, self.program_environment(), object_ty) { - return false; - } - - let has_declared_type = |expression: &ast::Expr| match expression { - ast::Expr::Name(name) => { - let place = PlaceExpr::from_expr_name(name); - if matches!( - self.infer_place_load(PlaceExprRef::from(&place), ast::ExprRef::Name(name)) - .0 - .place, - Place::Defined(DefinedPlace { - origin: TypeOrigin::Declared, - .. - }) - ) { - return true; - } - - let use_id = ast::ExprRef::Name(name).scoped_use_id(db, self.program_file()); - let use_def = self.index.use_def_map(self.scope.file_scope_id(db)); - use_def.bindings_at_use(use_id).any(|binding| { - binding.binding.definition().is_some_and(|definition| { - matches!(definition.kind(db), DefinitionKind::AnnotatedAssignment(_)) - || use_def - .declarations_at_binding(definition) - .any(|declaration| declaration.declaration.definition().is_some()) - }) - }) - } - ast::Expr::Attribute(attribute) => { - let receiver_ty = self.expression_type(&attribute.value); - matches!( - receiver_ty - .member(db, self.program_environment(), &attribute.attr.id) - .place, - Place::Defined(DefinedPlace { - origin: TypeOrigin::Declared, - .. - }) - ) - } - _ => false, - }; - - if has_declared_type(object) { - return false; - } - - let Some(name) = object.as_name_expr() else { - return true; - }; - let use_id = ast::ExprRef::Name(name).scoped_use_id(db, self.program_file()); - let use_def = self.index.use_def_map(self.scope.file_scope_id(db)); - - !use_def.bindings_at_use(use_id).any(|binding| { - binding - .binding - .definition() - .is_some_and(|definition| match definition.kind(db) { - DefinitionKind::Assignment(assignment) => { - has_declared_type(assignment.value(self.module())) - } - _ => false, - }) - }) - } - fn infer_dict_key_assignment_definition( &mut self, key: &'ast ast::Expr, From 05bf368f9a1009cfb526449b97507e4e8db57d3d Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 6 Aug 2026 09:10:09 -0400 Subject: [PATCH 3/7] [ty] Reuse assignment machinery for augmented stores --- .../resources/mdtest/assignment/augmented.md | 33 +++++++ .../src/types/infer/builder.rs | 85 +++++++++++-------- .../src/types/infer/builder/subscript.rs | 26 +----- 3 files changed, 86 insertions(+), 58 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md b/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md index 61e8b5b4888510..f81d08736caf5a 100644 --- a/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md +++ b/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md @@ -274,6 +274,24 @@ writable.value += 1 reveal_type(writable.value) # revealed: ReadValue ``` +Unannotated data descriptors still impose the write contract declared by their setter. + +```py +class Descriptor: + def __get__(self, instance: object, owner: type[object] | None = None) -> int: + return 1 + + def __set__(self, instance: object, value: str) -> None: + pass + +class Custom: + value = Descriptor() + +custom = Custom() +# error: [invalid-assignment] +custom.value += 1 +``` + ## Subscript targets An augmented subscript assignment must pass its result, not the operator's right-hand operand, to @@ -297,6 +315,21 @@ container[0] += 1 reveal_type(container[0]) # revealed: Value ``` +A custom setter may accept a broader type than its getter returns. + +```py +class PermissiveContainer: + def __getitem__(self, key: int) -> Value: + return Value() + + def __setitem__(self, key: int, value: object) -> None: + pass + +permissive = PermissiveContainer() +permissive[0] += 1 +reveal_type(permissive[0]) # revealed: Value +``` + Explicitly annotated lists and dictionaries retain their write contracts. ```py diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 2caf2d91cc25dc..9ec8472c7012b9 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -1485,7 +1485,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { fn fallback_member_declared_type(&mut self, node: AnyNodeRef<'_>) -> Option> { let db = self.db(); if let AnyNodeRef::ExprAttribute(ast::ExprAttribute { value, attr, .. }) = node { - let value_type = self.infer_maybe_standalone_expression(value, TypeContext::default()); + let value_type = self.try_expression_type(value).unwrap_or_else(|| { + self.infer_maybe_standalone_expression(value, TypeContext::default()) + }); if let Place::Defined(DefinedPlace { ty, definedness: Definedness::AlwaysDefined, @@ -1505,8 +1507,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }, ) = node { - let value_ty = self.infer_expression(value, TypeContext::default()); - let slice_ty = self.infer_expression(slice, TypeContext::default()); + let value_ty = self.get_or_infer_expression(value, TypeContext::default()); + let slice_ty = self.get_or_infer_expression(slice, TypeContext::default()); Some(self.infer_subscript_expression_types(subscript, value_ty, slice_ty, *ctx)) } else { None @@ -3213,13 +3215,24 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } ast::Expr::Subscript(subscript_expr) => { if let Some(infer_assigned_ty) = infer_assigned_ty { + let object_ty = + self.infer_expression(&subscript_expr.value, TypeContext::default()); + let mut infer_slice_ty = |builder: &mut Self, tcx| { + builder.infer_expression(&subscript_expr.slice, tcx) + }; let infer_assigned_ty = &mut |builder: &mut Self, tcx| { let assigned_ty = infer_assigned_ty(builder, tcx); builder.store_expression_type(target, assigned_ty); assigned_ty }; - self.validate_subscript_assignment(subscript_expr, value, infer_assigned_ty); + self.validate_subscript_assignment( + subscript_expr, + value, + object_ty, + &mut infer_slice_ty, + infer_assigned_ty, + ); } } @@ -4784,7 +4797,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { definition: Definition<'db>, ) { let target_ty = self.infer_augment_assignment(assignment); - self.add_binding(assignment.into(), definition) + self.add_binding(assignment.target.as_ref().into(), definition) .insert(self, target_ty); } @@ -4852,26 +4865,25 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { return result_ty; }; - let is_data_descriptor = - assignment_attribute_members(db, env, object_ty, &attribute.attr.id) - .and_then(AssignmentAttributeMembers::type_member) - .and_then(|member| member.place.ignore_possibly_undefined()) - .is_some_and(|ty| ty.may_be_data_descriptor(db, env)); - - // Inferred-only attributes can change type across augmented assignments. Concrete - // descriptors and qualified attributes still impose independent write contracts. + // Inferred-only attributes can change type, but descriptors and qualified + // attributes still impose independent write contracts. if !origin.is_declared() && !qualifiers.intersects( TypeQualifiers::FINAL | TypeQualifiers::CLASS_VAR | TypeQualifiers::READ_ONLY, ) - && !is_data_descriptor + && !AddBinding::attribute_is_data_descriptor( + db, + env, + object_ty, + &attribute.attr.id, + ) { return result_ty; } - let valid = self.validate_attribute_assignment( + self.validate_attribute_assignment( attribute, target, object_ty, @@ -4879,12 +4891,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { &mut |_, _| result_ty, true, ); - - if !valid || is_data_descriptor { - target_type - } else { - result_ty - } + result_ty } ast::Expr::Subscript(subscript) => { let object_ty = self.expression_type(&subscript.value); @@ -4900,15 +4907,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { return result_ty; } - if self.validate_augmented_subscript_assignment( - subscript, target, object_ty, slice_ty, result_ty, - ) && (object_ty.is_typed_dict() - || AddBinding::is_safe_mutable_class(db, env, object_ty)) - { - result_ty - } else { - target_type - } + self.validate_subscript_assignment( + subscript, + target, + object_ty, + &mut |_, _| slice_ty, + &mut |_, _| result_ty, + ); + result_ty } _ => result_ty, } @@ -12487,11 +12493,7 @@ impl<'db, 'ast> AddBinding<'db, 'ast> { builder.infer_maybe_standalone_expression(value, TypeContext::default()) }); // If the member is a data descriptor, the RHS value may differ from the value actually assigned. - if assignment_attribute_members(db, env, value_ty, &attr.id) - .and_then(AssignmentAttributeMembers::type_member) - .and_then(|member| member.place.ignore_possibly_undefined()) - .is_some_and(|ty| ty.may_be_data_descriptor(db, env)) - { + if Self::attribute_is_data_descriptor(db, env, value_ty, &attr.id) { builder.discard_dict_key_assignments_for(self.binding); bound_ty = declared_ty; } @@ -12511,6 +12513,19 @@ impl<'db, 'ast> AddBinding<'db, 'ast> { inferred_ty } + /// Return whether writes to this attribute are handled by a concrete data descriptor. + fn attribute_is_data_descriptor( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + object_ty: Type<'db>, + attribute: &str, + ) -> bool { + assignment_attribute_members(db, env, object_ty, attribute) + .and_then(AssignmentAttributeMembers::type_member) + .and_then(|member| member.place.ignore_possibly_undefined()) + .is_some_and(|ty| ty.may_be_data_descriptor(db, env)) + } + /// Arbitrary `__getitem__`/`__setitem__` methods on a class do not /// necessarily guarantee that the passed-in value for `__setitem__` is stored and /// can be retrieved unmodified via `__getitem__`. Therefore, we currently only diff --git a/crates/ty_python_semantic/src/types/infer/builder/subscript.rs b/crates/ty_python_semantic/src/types/infer/builder/subscript.rs index 58d21e98fb8a23..d78382e5eb2601 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/subscript.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/subscript.rs @@ -1553,6 +1553,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { &mut self, target: &ast::ExprSubscript, rhs_value: &ast::Expr, + object_ty: Type<'db>, + infer_slice_ty: &mut dyn FnMut(&mut Self, TypeContext<'db>) -> Type<'db>, infer_rhs_value: &mut dyn FnMut(&mut Self, TypeContext<'db>) -> Type<'db>, ) -> bool { let env = self.program_environment(); @@ -1566,15 +1568,13 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let db = self.db(); - let object_ty = self.infer_expression(object, TypeContext::default()); self.store_typed_dict_key_expected_type(slice, object_ty); - let mut infer_slice_ty = |builder: &mut Self, tcx| builder.infer_expression(slice, tcx); let is_valid_assignment = self.validate_subscript_assignment_impl( target, None, object_ty, - &mut infer_slice_ty, + infer_slice_ty, rhs_value, infer_rhs_value, true, @@ -1662,26 +1662,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { is_valid_assignment } - /// Validate the already-inferred value written by an augmented subscript assignment. - pub(super) fn validate_augmented_subscript_assignment( - &mut self, - target: &ast::ExprSubscript, - result_node: &ast::Expr, - object_ty: Type<'db>, - slice_ty: Type<'db>, - result_ty: Type<'db>, - ) -> bool { - self.validate_subscript_assignment_impl( - target, - None, - object_ty, - &mut |_, _| slice_ty, - result_node, - &mut |_, _| result_ty, - true, - ) - } - #[expect(clippy::too_many_arguments)] fn validate_subscript_assignment_impl( &mut self, From a6a127e4bef78272ab84683a83742e9ec12783b2 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 6 Aug 2026 09:51:50 -0400 Subject: [PATCH 4/7] [ty] Preserve inferred augmented attribute bindings --- .../resources/mdtest/assignment/augmented.md | 14 ++++++++++++++ .../src/types/infer/builder.rs | 19 ++++++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md b/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md index f81d08736caf5a..6e1bd7a247b08d 100644 --- a/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md +++ b/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md @@ -235,6 +235,20 @@ holder.inplace += 1 reveal_type(holder.inplace) # revealed: InplaceValue ``` +## Inferred attribute targets in loops + +An inferred attribute can change type across assignments. Its initial value must not become a +declaration that pollutes the loop-carried type after the attribute has been reassigned. + +```py +class Counter: + def update(self, increment: float) -> None: + self.value = None + self.value = 0 + for _ in range(1): + self.value += increment +``` + ## Attribute descriptors Even an in-place operation writes its result back, so a read-only property rejects augmented diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 9ec8472c7012b9..15858ccc05352b 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -1465,7 +1465,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } = place_and_quals; let declared_ty = if resolved_place.is_undefined() && !place.is_symbol() { - self.fallback_member_declared_type(node) + self.fallback_member_declared_type(node, binding) } else { None } @@ -1482,7 +1482,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { /// For a member binding without a live place declaration, obtain its declared type from /// normal attribute or subscript lookup on its receiver. - fn fallback_member_declared_type(&mut self, node: AnyNodeRef<'_>) -> Option> { + fn fallback_member_declared_type( + &mut self, + node: AnyNodeRef<'_>, + binding: Definition<'db>, + ) -> Option> { let db = self.db(); if let AnyNodeRef::ExprAttribute(ast::ExprAttribute { value, attr, .. }) = node { let value_type = self.try_expression_type(value).unwrap_or_else(|| { @@ -1490,11 +1494,20 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }); if let Place::Defined(DefinedPlace { ty, + origin, definedness: Definedness::AlwaysDefined, .. }) = value_type .member(db, self.program_environment(), attr) .place + && (!matches!(binding.kind(db), DefinitionKind::AugmentedAssignment(_)) + || origin.is_declared() + || AddBinding::attribute_is_data_descriptor( + db, + self.program_environment(), + value_type, + attr, + )) { // TODO: also consider qualifiers on the attribute Some(ty) @@ -4253,7 +4266,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let node = target.into(); let add = AddBinding { - declared_ty: self.fallback_member_declared_type(node), + declared_ty: self.fallback_member_declared_type(node, definition), binding: definition, node, qualifiers: TypeQualifiers::empty(), From 60b9e2faad00feb660927244c3658e9005155132 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 6 Aug 2026 13:46:53 -0400 Subject: [PATCH 5/7] [ty] Unify augmented assignment validation and inference --- .../resources/mdtest/assignment/augmented.md | 52 ++++- .../resources/mdtest/attributes.md | 5 +- .../src/types/class/static_literal.rs | 5 +- .../src/types/infer/builder.rs | 183 +++++------------- 4 files changed, 101 insertions(+), 144 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md b/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md index 6e1bd7a247b08d..a97c2065ffbaf4 100644 --- a/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md +++ b/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md @@ -247,6 +247,22 @@ class Counter: self.value = 0 for _ in range(1): self.value += increment + +reveal_type(Counter().value) # revealed: None | float +``` + +## Inferred public attribute targets + +An inferred class attribute has the same public write contract for augmented and ordinary +assignments. + +```py +class Holder: + value = 1 + +holder = Holder() +# error: [invalid-assignment] +holder.value += 0.5 ``` ## Attribute descriptors @@ -395,17 +411,20 @@ values[0] += 1 ## Failed attribute and subscript loads -If the load has already failed, its corresponding store must not emit another diagnostic. +Both the load and the store are checked, just as they are for an ordinary assignment whose value +reads the same target. ```py class Missing: ... missing = Missing() # error: [unresolved-attribute] +# error: [unresolved-attribute] missing.value += 1 mapping: dict[str, int] = {} # error: [invalid-argument-type] +# error: [invalid-assignment] mapping[1] += 1 ``` @@ -447,9 +466,40 @@ class B: value: BValue def update(value: A | B) -> None: + # TODO: Preserve receiver correlation, which is also lost in ordinary assignments. + # error: [invalid-assignment] value.value += 1 ``` +## Union subscript targets + +An augmented assignment must reject a union alternative that does not support the write. + +```py +def update(value: list[int] | tuple[int, ...]) -> None: + # error: [invalid-assignment] + value[0] += 1 +``` + +## Union subscript keys + +Each possible typed-dictionary key must accept the value written by the augmented assignment. + +```py +from typing import Literal, TypedDict + +class Payload(TypedDict): + first: int + second: int + +def update(value: Payload, key: Literal["first", "second"]) -> None: + value[key] += 1 + + # error: [invalid-assignment] + # error: [invalid-assignment] + value[key] /= 2 +``` + ## Inferred collection targets Augmented assignments do not yet participate in full-scope collection inference. diff --git a/crates/ty_python_semantic/resources/mdtest/attributes.md b/crates/ty_python_semantic/resources/mdtest/attributes.md index b488a2f05fb982..7c7396f9df6587 100644 --- a/crates/ty_python_semantic/resources/mdtest/attributes.md +++ b/crates/ty_python_semantic/resources/mdtest/attributes.md @@ -269,9 +269,8 @@ class C: self.w = Weird() self.w += None -# TODO: Mypy and pyright do not support this, but it would be great if we could -# infer `str` here (`Weird` is not a possible type for the `w` attribute). -reveal_type(C().w) # revealed: Weird +# TODO: Infer `str` alone, since the initial `Weird` value has been overwritten. +reveal_type(C().w) # revealed: Weird | str ``` #### Nested augmented assignments after narrowing diff --git a/crates/ty_python_semantic/src/types/class/static_literal.rs b/crates/ty_python_semantic/src/types/class/static_literal.rs index e36a3935af95c1..b94428ddf531b0 100644 --- a/crates/ty_python_semantic/src/types/class/static_literal.rs +++ b/crates/ty_python_semantic/src/types/class/static_literal.rs @@ -48,7 +48,7 @@ use crate::{ is_implicit_staticmethod, }, generics::Specialization, - infer::infer_unpack_types, + infer::{infer_definition_types, infer_unpack_types}, infer_expression_type, inferred_declaration, known_instance::DeprecatedInstance, member::{Member, class_member}, @@ -3079,8 +3079,7 @@ impl<'db> StaticClassLiteral<'db> { } } DefinitionKind::AugmentedAssignment(_) => { - // TODO: - None + Some(infer_definition_types(db, binding).binding_type(binding)) } DefinitionKind::NamedExpression(_) => { // A named expression whose target is an attribute is syntactically prohibited diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 15858ccc05352b..72eac4ab7db3a5 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -1465,7 +1465,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } = place_and_quals; let declared_ty = if resolved_place.is_undefined() && !place.is_symbol() { - self.fallback_member_declared_type(node, binding) + self.fallback_member_declared_type(node) } else { None } @@ -1482,11 +1482,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { /// For a member binding without a live place declaration, obtain its declared type from /// normal attribute or subscript lookup on its receiver. - fn fallback_member_declared_type( - &mut self, - node: AnyNodeRef<'_>, - binding: Definition<'db>, - ) -> Option> { + fn fallback_member_declared_type(&mut self, node: AnyNodeRef<'_>) -> Option> { let db = self.db(); if let AnyNodeRef::ExprAttribute(ast::ExprAttribute { value, attr, .. }) = node { let value_type = self.try_expression_type(value).unwrap_or_else(|| { @@ -1494,20 +1490,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }); if let Place::Defined(DefinedPlace { ty, - origin, definedness: Definedness::AlwaysDefined, .. }) = value_type .member(db, self.program_environment(), attr) .place - && (!matches!(binding.kind(db), DefinitionKind::AugmentedAssignment(_)) - || origin.is_declared() - || AddBinding::attribute_is_data_descriptor( - db, - self.program_environment(), - value_type, - attr, - )) { // TODO: also consider qualifiers on the attribute Some(ty) @@ -4266,7 +4253,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let node = target.into(); let add = AddBinding { - declared_ty: self.fallback_member_declared_type(node, definition), + declared_ty: self.fallback_member_declared_type(node), binding: definition, node, qualifiers: TypeQualifiers::empty(), @@ -4656,26 +4643,37 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.infer_definition(assignment); } else { // Non-name assignment targets are inferred as ordinary expressions, not definitions. - self.infer_augment_assignment(assignment); + if let Ok(result_ty) = self.infer_augment_assignment(assignment) { + let target = assignment.target.as_ref(); + match target { + ast::Expr::Attribute(attribute) => { + let object_ty = self.expression_type(&attribute.value); + self.validate_attribute_assignment( + attribute, + target, + object_ty, + attribute.attr.id(), + &mut |_, _| result_ty, + true, + ); + } + ast::Expr::Subscript(subscript) => { + let object_ty = self.expression_type(&subscript.value); + let slice_ty = self.expression_type(&subscript.slice); + self.validate_subscript_assignment( + subscript, + target, + object_ty, + &mut |_, _| slice_ty, + &mut |_, _| result_ty, + ); + } + _ => {} + } + } if let ast::Expr::Attribute(attr_expr) = assignment.target.as_ref() { - let object_ty = self.expression_type(&attr_expr.value); self.report_undeclared_protocol_attribute(attr_expr); - - // Composite receiver stores are outside the scope of augmented-store validation, - // but their existing `Final` checks must continue to apply. - if object_ty.as_union_like(self.db()).is_some() - || matches!( - object_ty.resolve_type_alias(self.db()), - Type::Intersection(_) - ) - { - self.validate_final_attribute_assignment( - attr_expr, - object_ty, - attr_expr.attr.id(), - ); - } } } } @@ -4809,12 +4807,17 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { assignment: &'ast ast::StmtAugAssign, definition: Definition<'db>, ) { - let target_ty = self.infer_augment_assignment(assignment); + let target_ty = self + .infer_augment_assignment(assignment) + .unwrap_or_else(|recovery_ty| recovery_ty); self.add_binding(assignment.target.as_ref().into(), definition) .insert(self, target_ty); } - fn infer_augment_assignment(&mut self, assignment: &ast::StmtAugAssign) -> Type<'db> { + fn infer_augment_assignment( + &mut self, + assignment: &ast::StmtAugAssign, + ) -> Result, Type<'db>> { let ast::StmtAugAssign { range: _, node_index: _, @@ -4843,94 +4846,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { _ => self.infer_expression(target, TypeContext::default()), }; - let result_ty = - match self.infer_augmented_op(assignment, target_type, value, &mut |builder, tcx| { - builder.infer_expression(value, tcx) - }) { - Ok(result_ty) => result_ty, - Err(recovery_ty) => return recovery_ty, - }; - - let db = self.db(); - let env = self.program_environment(); - match target.as_ref() { - ast::Expr::Attribute(attribute) => { - let object_ty = self.expression_type(&attribute.value); - - // A union or intersection requires correlating each receiver with its own - // operator result; validating the combined result would reject valid programs. - if object_ty.as_union_like(db).is_some() - || matches!(object_ty.resolve_type_alias(db), Type::Intersection(_)) - { - return result_ty; - } - - let PlaceAndQualifiers { - place: - Place::Defined(DefinedPlace { - origin, - definedness: Definedness::AlwaysDefined, - .. - }), - qualifiers, - } = object_ty.member(db, env, &attribute.attr.id) - else { - return result_ty; - }; - - // Inferred-only attributes can change type, but descriptors and qualified - // attributes still impose independent write contracts. - if !origin.is_declared() - && !qualifiers.intersects( - TypeQualifiers::FINAL - | TypeQualifiers::CLASS_VAR - | TypeQualifiers::READ_ONLY, - ) - && !AddBinding::attribute_is_data_descriptor( - db, - env, - object_ty, - &attribute.attr.id, - ) - { - return result_ty; - } - - self.validate_attribute_assignment( - attribute, - target, - object_ty, - attribute.attr.id(), - &mut |_, _| result_ty, - true, - ); - result_ty - } - ast::Expr::Subscript(subscript) => { - let object_ty = self.expression_type(&subscript.value); - let slice_ty = self.expression_type(&subscript.slice); - - if object_ty.as_union_like(db).is_some() - || slice_ty.as_union_like(db).is_some() - || matches!(object_ty.resolve_type_alias(db), Type::Intersection(_)) - || object_ty - .subscript(db, env, slice_ty, ExprContext::Load) - .is_err() - { - return result_ty; - } - - self.validate_subscript_assignment( - subscript, - target, - object_ty, - &mut |_, _| slice_ty, - &mut |_, _| result_ty, - ); - result_ty - } - _ => result_ty, - } + self.infer_augmented_op(assignment, target_type, value, &mut |builder, tcx| { + builder.infer_expression(value, tcx) + }) } fn infer_dict_key_assignment_definition( @@ -12506,7 +12424,11 @@ impl<'db, 'ast> AddBinding<'db, 'ast> { builder.infer_maybe_standalone_expression(value, TypeContext::default()) }); // If the member is a data descriptor, the RHS value may differ from the value actually assigned. - if Self::attribute_is_data_descriptor(db, env, value_ty, &attr.id) { + if assignment_attribute_members(db, env, value_ty, &attr.id) + .and_then(AssignmentAttributeMembers::type_member) + .and_then(|member| member.place.ignore_possibly_undefined()) + .is_some_and(|ty| ty.may_be_data_descriptor(db, env)) + { builder.discard_dict_key_assignments_for(self.binding); bound_ty = declared_ty; } @@ -12526,19 +12448,6 @@ impl<'db, 'ast> AddBinding<'db, 'ast> { inferred_ty } - /// Return whether writes to this attribute are handled by a concrete data descriptor. - fn attribute_is_data_descriptor( - db: &'db dyn Db, - env: &ProgramEnvironment<'db>, - object_ty: Type<'db>, - attribute: &str, - ) -> bool { - assignment_attribute_members(db, env, object_ty, attribute) - .and_then(AssignmentAttributeMembers::type_member) - .and_then(|member| member.place.ignore_possibly_undefined()) - .is_some_and(|ty| ty.may_be_data_descriptor(db, env)) - } - /// Arbitrary `__getitem__`/`__setitem__` methods on a class do not /// necessarily guarantee that the passed-in value for `__setitem__` is stored and /// can be retrieved unmodified via `__getitem__`. Therefore, we currently only From bdd36b39f62a0a17f07fca4206174074a6446059 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 6 Aug 2026 22:13:04 -0400 Subject: [PATCH 6/7] [ty] Avoid cascading diagnostics for failed augmented assignment targets --- .../resources/mdtest/assignment/augmented.md | 61 +++++++- .../src/types/infer/builder.rs | 63 +++++--- .../src/types/infer/builder/subscript.rs | 147 ++++++++++-------- 3 files changed, 184 insertions(+), 87 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md b/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md index a97c2065ffbaf4..ac114c7021a173 100644 --- a/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md +++ b/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md @@ -409,23 +409,74 @@ values: tuple[int] = (1,) values[0] += 1 ``` -## Failed attribute and subscript loads +## Failed attribute loads -Both the load and the store are checked, just as they are for an ordinary assignment whose value -reads the same target. +A missing attribute prevents the assignment from reaching its write phase, so the failed read is +reported only once. ```py class Missing: ... missing = Missing() # error: [unresolved-attribute] -# error: [unresolved-attribute] missing.value += 1 +``` + +Missing attributes on functions and classes also fail before the write phase. + +```py +def callback() -> None: + pass + +# error: [unresolved-attribute] +callback.count += 1 + +# error: [unresolved-attribute] +Missing.value += 1 +``` + +An attribute that is missing from one union alternative likewise cannot be written. + +```py +class Counter: + count: int + +def update(counter: Counter | None) -> None: + # error: [unresolved-attribute] + counter.count += 1 +``` + +## Failed subscript loads +A failed subscript read also prevents the assignment from reaching its write phase. + +```py mapping: dict[str, int] = {} # error: [invalid-argument-type] -# error: [invalid-assignment] mapping[1] += 1 + +value = 1 +# error: [not-subscriptable] +value[0] += 1 +``` + +## Failed loads still infer the right-hand side + +The right-hand side is still inferred after a failed read so that its independent errors are +reported. + +```py +class Missing: ... + +missing = Missing() +# error: [unresolved-attribute] +# error: [unresolved-reference] +missing.value += missing_attribute_operand + +mapping: dict[str, int] = {} +# error: [invalid-argument-type] +# error: [unresolved-reference] +mapping[1] += missing_subscript_operand ``` ## Failed augmented operations diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 72eac4ab7db3a5..9b3463a74481ed 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -1509,7 +1509,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { { let value_ty = self.get_or_infer_expression(value, TypeContext::default()); let slice_ty = self.get_or_infer_expression(slice, TypeContext::default()); - Some(self.infer_subscript_expression_types(subscript, value_ty, slice_ty, *ctx)) + Some( + self.infer_subscript_expression_types(subscript, value_ty, slice_ty, *ctx) + .unwrap_or_else(|recovery_ty| recovery_ty), + ) } else { None } @@ -4827,28 +4830,37 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } = assignment; // Resolve the target type, assuming a load context. - let target_type = match &**target { + let target_result = match &**target { ast::Expr::Name(name) => { let previous_value = self.infer_name_load(name); self.store_expression_type(target, previous_value); - previous_value + Ok(previous_value) } ast::Expr::Attribute(attr) => { - let previous_value = self.infer_attribute_load(attr); + let result = self.infer_attribute_load(attr); + let previous_value = result.unwrap_or_else(|recovery_ty| recovery_ty); self.store_expression_type(target, previous_value); - previous_value + result } ast::Expr::Subscript(subscript) => { - let previous_value = self.infer_subscript_load(subscript); + let result = self.infer_subscript_load(subscript); + let previous_value = result.unwrap_or_else(|recovery_ty| recovery_ty); self.store_expression_type(target, previous_value); - previous_value + result } - _ => self.infer_expression(target, TypeContext::default()), + _ => Ok(self.infer_expression(target, TypeContext::default())), }; - self.infer_augmented_op(assignment, target_type, value, &mut |builder, tcx| { - builder.infer_expression(value, tcx) - }) + let target_type = target_result.unwrap_or_else(|recovery_ty| recovery_ty); + let operation_result = + self.infer_augmented_op(assignment, target_type, value, &mut |builder, tcx| { + builder.infer_expression(value, tcx) + }); + + match (target_result, operation_result) { + (Ok(_), Ok(result_ty)) => Ok(result_ty), + (_, Ok(recovery_ty) | Err(recovery_ty)) => Err(recovery_ty), + } } fn infer_dict_key_assignment_definition( @@ -9258,6 +9270,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let collection_generic_context = collection_literal.generic_context(db); let mut identity_bindings = self .infer_attribute_load_impl(attribute, identity_instance) + .unwrap_or_else(|recovery_ty| recovery_ty) .bindings(db, env) .match_parameters(db, env, &call_arguments) // Perform inference against the type variables on the receiver's generic context. @@ -10318,19 +10331,22 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } - /// Infer the type of a [`ast::ExprAttribute`] expression, assuming a load context. - fn infer_attribute_load(&mut self, attribute: &ast::ExprAttribute) -> Type<'db> { + /// Infer an attribute load, returning its recovery type if lookup fails. + fn infer_attribute_load( + &mut self, + attribute: &ast::ExprAttribute, + ) -> Result, Type<'db>> { let value_type = self.infer_maybe_standalone_expression(&attribute.value, TypeContext::default()); self.infer_attribute_load_impl(attribute, value_type) } - /// Infer the type of a [`ast::ExprAttribute`] expression, assuming a load context. + /// Infer an attribute load on a known receiver, returning its recovery type if lookup fails. fn infer_attribute_load_impl( &mut self, attribute: &ast::ExprAttribute, mut value_type: Type<'db>, - ) -> Type<'db> { + ) -> Result, Type<'db>> { fn union_elements_missing_attribute<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, @@ -10393,8 +10409,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }); let attr_name = &attr.id; - let resolved_type = - fallback_place.unwrap_with_diagnostic(db, env, |lookup_err| match lookup_err { + let lookup_result = fallback_place.into_lookup_result(db, env); + let resolved_type = lookup_result.unwrap_or_else(|lookup_err| match lookup_err { LookupError::Undefined(_) => { let fallback = || { TypeAndQualifiers::new( @@ -10651,7 +10667,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { type_when_bound } - }); + }); let resolved_type = resolved_type.inner_type(); @@ -10659,7 +10675,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // Even if we can obtain the attribute type based on the assignments, we still perform default type inference // (to report errors). - assigned_type.unwrap_or(resolved_type) + let inferred_type = assigned_type.unwrap_or(resolved_type); + lookup_result + .map(|_| inferred_type) + .map_err(|_| inferred_type) } fn infer_attribute_expression(&mut self, attribute: &ast::ExprAttribute) -> Type<'db> { @@ -10672,13 +10691,15 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } = attribute; match ctx { - ExprContext::Load => self.infer_attribute_load(attribute), + ExprContext::Load => self + .infer_attribute_load(attribute) + .unwrap_or_else(|recovery_ty| recovery_ty), ExprContext::Store => { self.infer_expression(value, TypeContext::default()); Type::Never } ExprContext::Del => { - self.infer_attribute_load(attribute); + let _ = self.infer_attribute_load(attribute); self.validate_attribute_deletion( attribute, self.expression_type(value), diff --git a/crates/ty_python_semantic/src/types/infer/builder/subscript.rs b/crates/ty_python_semantic/src/types/infer/builder/subscript.rs index d78382e5eb2601..f5f9b99e2f4151 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/subscript.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/subscript.rs @@ -136,12 +136,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } = subscript; match ctx { - ExprContext::Load => self.infer_subscript_load(subscript), + ExprContext::Load => self + .infer_subscript_load(subscript) + .unwrap_or_else(|recovery_ty| recovery_ty), ExprContext::Store => { let value_ty = self.infer_expression(value, TypeContext::default()); self.store_typed_dict_key_expected_type(slice, value_ty); let slice_ty = self.infer_expression(slice, TypeContext::default()); - self.infer_subscript_expression_types(subscript, value_ty, slice_ty, *ctx); + let _ = self.infer_subscript_expression_types(subscript, value_ty, slice_ty, *ctx); Type::Never } ExprContext::Del => { @@ -154,20 +156,24 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ExprContext::Invalid => { let value_ty = self.infer_expression(value, TypeContext::default()); let slice_ty = self.infer_expression(slice, TypeContext::default()); - self.infer_subscript_expression_types(subscript, value_ty, slice_ty, *ctx); + let _ = self.infer_subscript_expression_types(subscript, value_ty, slice_ty, *ctx); Type::unknown() } } } - pub(super) fn infer_subscript_load(&mut self, subscript: &ast::ExprSubscript) -> Type<'db> { + /// Infer a subscript load, returning its recovery type if the subscription fails. + pub(super) fn infer_subscript_load( + &mut self, + subscript: &ast::ExprSubscript, + ) -> Result, Type<'db>> { let value_ty = self.infer_expression(&subscript.value, TypeContext::default()); // If we have an implicit type alias like `MyList = list[T]`, and if `MyList` is being // used in another implicit type alias like `Numbers = MyList[int]`, then we infer the // right hand side as a value expression, and need to handle the specialization here. if value_ty.is_generic_alias() { - return self.infer_explicit_type_alias_specialization(subscript, value_ty, false); + return Ok(self.infer_explicit_type_alias_specialization(subscript, value_ty, false)); } self.infer_subscript_load_impl(value_ty, subscript) @@ -177,7 +183,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { &mut self, value_ty: Type<'db>, subscript: &ast::ExprSubscript, - ) -> Type<'db> { + ) -> Result, Type<'db>> { let env = self.program_environment(); let db = self.db(); @@ -186,7 +192,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { node_index: _, value: _, slice, - ctx: expr_context, + ctx: _, } = subscript; self.store_typed_dict_key_expected_type(slice, value_ty); @@ -210,13 +216,15 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // Even if we can obtain the subscript type based on the assignments, we still perform default type inference // (to store the expression type and to report errors). let slice_ty = self.infer_expression(slice, TypeContext::default()); - self.infer_subscript_expression_types( - subscript, - value_ty, - slice_ty, - *expr_context, - ); - return ty; + return self + .infer_subscript_expression_types( + subscript, + value_ty, + slice_ty, + ExprContext::Load, + ) + .map(|_| ty) + .map_err(|_| ty); } } } @@ -235,43 +243,49 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // updating all of the subscript logic below to use custom callables for all of the _other_ // special cases, too. if class.is_tuple(db) { - return tuple_generic_alias(env, self.infer_tuple_type_expression(subscript)); + return Ok(tuple_generic_alias( + env, + self.infer_tuple_type_expression(subscript), + )); } else if class.is_known(db, KnownClass::Type) { let argument_ty = self.infer_type_expression(slice); - return Type::KnownInstance(KnownInstanceType::TypeGenericAlias( + return Ok(Type::KnownInstance(KnownInstanceType::TypeGenericAlias( InternedType::new(db, argument_ty), - )); + ))); } if let Some(generic_context) = class.generic_context(db) && let Some(class) = class.as_static() { - return self.infer_explicit_class_specialization( + return Ok(self.infer_explicit_class_specialization( subscript, value_ty, class, generic_context, - ); + )); } } Type::KnownInstance(KnownInstanceType::TypeAliasType(type_alias)) => { if let Some(generic_context) = type_alias.generic_context(db) { - return self.infer_explicit_type_alias_type_specialization( + return Ok(self.infer_explicit_type_alias_type_specialization( subscript, value_ty, type_alias, generic_context, - ); + )); } } Type::SpecialForm(special_form) => match special_form { SpecialFormType::Tuple => { - return tuple_generic_alias(env, self.infer_tuple_type_expression(subscript)); + return Ok(tuple_generic_alias( + env, + self.infer_tuple_type_expression(subscript), + )); } SpecialFormType::Literal => match self.infer_literal_parameter_type(slice) { Ok(result) => { - return Type::KnownInstance(KnownInstanceType::Literal(InternedType::new( - db, result, + return Ok(Type::KnownInstance(KnownInstanceType::Literal( + InternedType::new(db, result), ))); } Err(nodes) => { @@ -285,16 +299,16 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { a literal value (int, bool, str, or bytes), or an enum member", ); } - return Type::unknown(); + return Ok(Type::unknown()); } }, SpecialFormType::Annotated => { - return self + return Ok(self .parse_subscription_of_annotated_special_form( subscript, AnnotatedExprContext::TypeExpression, ) - .inner_type(); + .inner_type()); } SpecialFormType::Optional => { if matches!(**slice, ast::Expr::Tuple(_)) @@ -310,9 +324,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // `Optional[None]` is equivalent to `None`: if ty.is_none(db) { - return ty; + return Ok(ty); } - return Type::KnownInstance(KnownInstanceType::UnionType( + return Ok(Type::KnownInstance(KnownInstanceType::UnionType( UnionTypeInstance::new( db, None, @@ -323,7 +337,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { Type::none(db, env), )), ), - )); + ))); } SpecialFormType::Union => match **slice { ast::Expr::Tuple(ref tuple) => { @@ -346,18 +360,18 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ); } - return union_type; + return Ok(union_type); } _ => { - return self.infer_expression(slice, TypeContext::default()); + return Ok(self.infer_expression(slice, TypeContext::default())); } }, SpecialFormType::Type => { // Similar to the branch above that handles `type[…]`, handle `typing.Type[…]` let argument_ty = self.infer_type_expression(slice); - return Type::KnownInstance(KnownInstanceType::TypeGenericAlias( + return Ok(Type::KnownInstance(KnownInstanceType::TypeGenericAlias( InternedType::new(db, argument_ty), - )); + ))); } SpecialFormType::TypingCallable | SpecialFormType::CollectionsAbcCallable => { let callable = self @@ -365,7 +379,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .as_callable() .expect("always returns Type::Callable"); - return Type::KnownInstance(KnownInstanceType::Callable(callable)); + return Ok(Type::KnownInstance(KnownInstanceType::Callable(callable))); } SpecialFormType::Unpack => { self.store_type_expression_flags( @@ -383,19 +397,21 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { previously_in_unpack_type_argument, ); - return if matches!( - inner_ty, - Type::TypeVar(typevar) if typevar.is_typevartuple(db) - ) || inner_ty.exact_tuple_instance_spec(db).is_some() - { - inner_ty - } else { - self.store_type_expression_flags( - ast::ExprRef::from(subscript), - TypeExpressionFlags::INVALID_UNPACK, - ); - Type::unknown() - }; + return Ok( + if matches!( + inner_ty, + Type::TypeVar(typevar) if typevar.is_typevartuple(db) + ) || inner_ty.exact_tuple_instance_spec(db).is_some() + { + inner_ty + } else { + self.store_type_expression_flags( + ast::ExprRef::from(subscript), + TypeExpressionFlags::INVALID_UNPACK, + ); + Type::unknown() + }, + ); } SpecialFormType::LegacyStdlibAlias(alias) => { let AliasSpec { @@ -431,10 +447,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .map(|arg| self.infer_type_expression(arg)) .collect(); - return class + return Ok(class .to_specialized_class_type(db, env, arg_types) .map(Type::from) - .unwrap_or_else(Type::unknown); + .unwrap_or_else(Type::unknown)); } _ => {} }, @@ -445,7 +461,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { | KnownInstanceType::Callable(_) | KnownInstanceType::TypeGenericAlias(_), ) => { - return self.infer_explicit_type_alias_specialization(subscript, value_ty, false); + return Ok( + self.infer_explicit_type_alias_specialization(subscript, value_ty, false) + ); } Type::Dynamic(DynamicType::Unknown) => { let slice_ty = self.infer_expression(slice, TypeContext::default()); @@ -457,15 +475,21 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { &mut variables, ); let generic_context = GenericContext::from_typevar_instances(db, env, variables); - return Type::Dynamic(DynamicType::UnknownGeneric(generic_context)); + return Ok(Type::Dynamic(DynamicType::UnknownGeneric(generic_context))); } _ => {} } let slice_ty = self.infer_expression(slice, TypeContext::default()); - let result_ty = - self.infer_subscript_expression_types(subscript, value_ty, slice_ty, *expr_context); - self.narrow_expr_with_applicable_constraints(subscript, result_ty, &constraint_keys) + self.infer_subscript_expression_types(subscript, value_ty, slice_ty, ExprContext::Load) + .map(|ty| self.narrow_expr_with_applicable_constraints(subscript, ty, &constraint_keys)) + .map_err(|recovery_ty| { + self.narrow_expr_with_applicable_constraints( + subscript, + recovery_ty, + &constraint_keys, + ) + }) } pub(super) fn infer_explicit_class_specialization( @@ -1396,13 +1420,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { Err(()) } + /// Infer a subscription and report failures while preserving their recovery types. pub(super) fn infer_subscript_expression_types( &self, subscript: &ast::ExprSubscript, value_ty: Type<'db>, slice_ty: Type<'db>, expr_context: ExprContext, - ) -> Type<'db> { + ) -> Result, Type<'db>> { let env = self.program_environment(); let db = self.db(); @@ -1462,7 +1487,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { SubscriptErrorKind::MultipleTypeVarTuples { origin }, ); error.report_diagnostics(&self.context, subscript); - return error.result_type(); + return Err(error.result_type()); } if has_invalid_unpack_argument { let error = SubscriptError::new( @@ -1473,7 +1498,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }, ); error.report_diagnostics(&self.context, subscript); - return error.result_type(); + return Err(error.result_type()); } } @@ -1516,9 +1541,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { _ => value_ty.subscript(db, env, slice_ty, expr_context), }; - subscript_result.unwrap_or_else(|e| { - e.report_diagnostics(&self.context, subscript); - e.result_type() + subscript_result.map_err(|error| { + error.report_diagnostics(&self.context, subscript); + error.result_type() }) } From 21f50f7a17f65655f0f4f1d33d34bdf9c9e3af41 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 7 Aug 2026 07:48:40 -0400 Subject: [PATCH 7/7] [ty] Refine augmented assignment mdtests --- .../resources/mdtest/assignment/augmented.md | 266 +++++++++--------- .../resources/mdtest/attributes.md | 5 +- 2 files changed, 132 insertions(+), 139 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md b/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md index ac114c7021a173..9c368d9618f72a 100644 --- a/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md +++ b/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md @@ -187,74 +187,63 @@ def f(flag: bool, flag2: bool): reveal_type(f) # revealed: float | str ``` -## Annotated name targets +## Declared attributes with in-place operators -An augmented assignment to an annotated name must validate its result against the declaration. An -unannotated name can instead change type. +`+=` assigns the value returned by `__iadd__` back to its target. That value must be compatible with +the attribute's declared type. ```py class Value: - def __add__(self, other: int) -> object: - return other + def __iadd__(self, other: int) -> str: + return "updated" -annotated: Value = Value() -# error: [invalid-assignment] -annotated += 1 -reveal_type(annotated) # revealed: Value +class Holder: + value: Value -inferred = Value() -inferred += 1 -reveal_type(inferred) # revealed: object +holder = Holder() +# error: [invalid-assignment] +holder.value += 1 +reveal_type(holder.value) # revealed: Value ``` -## Attribute targets +## Declared attributes without in-place operators -The result must satisfy the attribute's write contract, whether the operation uses `__iadd__` or -falls back to `__add__`. +When an object does not define `__iadd__`, `+=` falls back to `__add__`. Its result must still be +compatible with the attribute's declared type. ```py -class AddValue: - def __add__(self, other: int) -> object: - return other - -class InplaceValue: - def __iadd__(self, other: int) -> object: - return other +class Value: + def __add__(self, other: int) -> str: + return "updated" class Holder: - add: AddValue - inplace: InplaceValue + value: Value holder = Holder() # error: [invalid-assignment] -holder.add += 1 -reveal_type(holder.add) # revealed: AddValue - -# error: [invalid-assignment] -holder.inplace += 1 -reveal_type(holder.inplace) # revealed: InplaceValue +holder.value += 1 ``` -## Inferred attribute targets in loops +## Inferred attributes in loops -An inferred attribute can change type across assignments. Its initial value must not become a -declaration that pollutes the loop-carried type after the attribute has been reassigned. +An unannotated instance attribute may change type. After its initial `None` value is replaced, an +augmented assignment inside a loop must also contribute its result to the inferred attribute type. ```py class Counter: - def update(self, increment: float) -> None: + def update(self) -> None: self.value = None self.value = 0 for _ in range(1): - self.value += increment + self.value += 1.0 reveal_type(Counter().value) # revealed: None | float ``` -## Inferred public attribute targets +## Inferred class attributes -An inferred class attribute has the same public write contract for augmented and ordinary -assignments. +An unannotated class attribute still has an inferred type that restricts assignments through an +instance. ```py class Holder: @@ -265,10 +254,10 @@ holder = Holder() holder.value += 0.5 ``` -## Attribute descriptors +## Read-only properties -Even an in-place operation writes its result back, so a read-only property rejects augmented -assignment. +`+=` writes its result back to the attribute. A property without a setter therefore cannot be the +target of an augmented assignment. ```py class ReadOnly: @@ -279,32 +268,32 @@ class ReadOnly: read_only = ReadOnly() # error: [invalid-assignment] read_only.value += 1 -reveal_type(read_only.value) # revealed: int ``` -A property's setter can accept a different type than its getter returns. The operation's result must -satisfy the setter, while later reads continue to use the getter type. +## Properties with different getter and setter types -```py -class ReadValue: - def __iadd__(self, other: int) -> str: - return "updated" +A property can accept a wider type in its setter than it returns from its getter. The result of `/=` +is checked against the setter, while subsequent reads still use the getter's return type. -class Writable: +```py +class Counter: @property - def value(self) -> ReadValue: - return ReadValue() + def value(self) -> int: + return 1 @value.setter - def value(self, value: str) -> None: + def value(self, value: float) -> None: pass -writable = Writable() -writable.value += 1 -reveal_type(writable.value) # revealed: ReadValue +counter = Counter() +counter.value /= 2 +reveal_type(counter.value) # revealed: int ``` -Unannotated data descriptors still impose the write contract declared by their setter. +## Attributes defined by descriptors + +When an unannotated class attribute is a data descriptor, its `__set__` method determines which +values may be assigned. ```py class Descriptor: @@ -314,94 +303,98 @@ class Descriptor: def __set__(self, instance: object, value: str) -> None: pass -class Custom: +class Holder: value = Descriptor() -custom = Custom() +holder = Holder() # error: [invalid-assignment] -custom.value += 1 +holder.value += 1 ``` -## Subscript targets +## Custom subscript assignments -An augmented subscript assignment must pass its result, not the operator's right-hand operand, to -the target's `__setitem__` method. +`/=` first reads an item, then writes the result back through `__setitem__`. The assigned value is +the result of the operation, not the right-hand operand. ```py -class Value: - def __iadd__(self, other: int) -> object: - return other - class Container: - def __getitem__(self, key: int) -> Value: - return Value() + def __getitem__(self, key: int) -> int: + return 1 - def __setitem__(self, key: int, value: Value) -> None: + def __setitem__(self, key: int, value: int) -> None: pass container = Container() # error: [invalid-assignment] -container[0] += 1 -reveal_type(container[0]) # revealed: Value +container[0] /= 2 +reveal_type(container[0]) # revealed: int ``` -A custom setter may accept a broader type than its getter returns. +## Subscript setters with different value types + +A collection can accept a wider type in `__setitem__` than `__getitem__` returns. After a valid +assignment, subsequent reads still use the return type of `__getitem__`. ```py -class PermissiveContainer: - def __getitem__(self, key: int) -> Value: - return Value() +class Container: + def __getitem__(self, key: int) -> int: + return 1 - def __setitem__(self, key: int, value: object) -> None: + def __setitem__(self, key: int, value: float) -> None: pass -permissive = PermissiveContainer() -permissive[0] += 1 -reveal_type(permissive[0]) # revealed: Value +container = Container() +container[0] /= 2 +reveal_type(container[0]) # revealed: int ``` -Explicitly annotated lists and dictionaries retain their write contracts. +## Annotated collection entries + +An annotation fixes the element type of a list, so `/=` cannot write a `float` into a `list[int]`. ```py -items: list[Value] = [Value()] +values: list[int] = [1] # error: [invalid-assignment] -items[0] += 1 -reveal_type(items[0]) # revealed: Value +values[0] /= 2 +``` + +The same rule applies to the value type of an annotated dictionary. -mapping: dict[str, Value] = {"value": Value()} +```py +mapping: dict[str, int] = {"value": 1} # error: [invalid-assignment] -mapping["value"] += 1 -reveal_type(mapping["value"]) # revealed: Value +mapping["value"] /= 2 ``` -Declared collection-valued attributes also retain their write contracts. +An annotated collection remains constrained when it is accessed through an attribute. ```py class Holder: - values: list[Value] + values: list[int] holder = Holder() # error: [invalid-assignment] -holder.values[0] += 1 +holder.values[0] /= 2 ``` -Typed dictionary entries validate the value written back to their declared fields. +## Typed dictionary entries + +A `TypedDict` field can only be assigned a value compatible with its declared type. ```py from typing import TypedDict class Payload(TypedDict): - value: Value + value: int -payload: Payload = {"value": Value()} +payload: Payload = {"value": 1} # error: [invalid-assignment] -payload["value"] += 1 -reveal_type(payload["value"]) # revealed: Value +payload["value"] /= 2 ``` ## Read-only subscripts -A readable subscript is not necessarily writable. +A readable item cannot be reassigned when its container does not implement `__setitem__`. ```py values: tuple[int] = (1,) @@ -409,10 +402,10 @@ values: tuple[int] = (1,) values[0] += 1 ``` -## Failed attribute loads +## Missing attributes -A missing attribute prevents the assignment from reaching its write phase, so the failed read is -reported only once. +If an augmented assignment cannot read its target, it must report that failure only once; no +assignment is attempted. ```py class Missing: ... @@ -422,20 +415,7 @@ missing = Missing() missing.value += 1 ``` -Missing attributes on functions and classes also fail before the write phase. - -```py -def callback() -> None: - pass - -# error: [unresolved-attribute] -callback.count += 1 - -# error: [unresolved-attribute] -Missing.value += 1 -``` - -An attribute that is missing from one union alternative likewise cannot be written. +The same applies when an attribute is missing from one member of a union. ```py class Counter: @@ -446,24 +426,29 @@ def update(counter: Counter | None) -> None: counter.count += 1 ``` -## Failed subscript loads +## Invalid subscript reads -A failed subscript read also prevents the assignment from reaching its write phase. +An invalid key prevents an item from being read, so the failed assignment must not produce a second +error. ```py mapping: dict[str, int] = {} # error: [invalid-argument-type] mapping[1] += 1 +``` +A value without `__getitem__` also fails before assignment can be attempted. + +```py value = 1 # error: [not-subscriptable] value[0] += 1 ``` -## Failed loads still infer the right-hand side +## Right-hand-side errors after failed reads -The right-hand side is still inferred after a failed read so that its independent errors are -reported. +Even when an attribute cannot be read, the right-hand side must still be checked for unrelated +errors. ```py class Missing: ... @@ -472,21 +457,25 @@ missing = Missing() # error: [unresolved-attribute] # error: [unresolved-reference] missing.value += missing_attribute_operand +``` +The same rule applies when a subscript cannot be read. + +```py mapping: dict[str, int] = {} # error: [invalid-argument-type] # error: [unresolved-reference] mapping[1] += missing_subscript_operand ``` -## Failed augmented operations +## Failed in-place operations -An operation that cannot run does not perform a store. +If `__iadd__` rejects its operand, its return type must not be treated as a value to assign. ```py class Value: - def __iadd__(self, other: int) -> object: - return other + def __iadd__(self, other: int) -> str: + return "updated" class Holder: value: Value @@ -496,10 +485,11 @@ holder = Holder() holder.value += "invalid" ``` -## Correlated union targets +## Union attribute assignments -The result of an operation on one union member must not be checked against another member's write -contract. +When objects in a union have different attribute types, each operator result should be checked +against the attribute from the same object. Ordinary assignments already lose this relationship, so +augmented assignments currently report the same false positive. ```py class AValue: @@ -517,14 +507,15 @@ class B: value: BValue def update(value: A | B) -> None: - # TODO: Preserve receiver correlation, which is also lost in ordinary assignments. + # TODO: Check each result against the attribute it came from. # error: [invalid-assignment] value.value += 1 ``` -## Union subscript targets +## Collections that may be read-only -An augmented assignment must reject a union alternative that does not support the write. +When a collection could be a writable list or a read-only tuple, an item assignment is invalid +because it cannot be performed on every possible value. ```py def update(value: list[int] | tuple[int, ...]) -> None: @@ -532,32 +523,31 @@ def update(value: list[int] | tuple[int, ...]) -> None: value[0] += 1 ``` -## Union subscript keys +## Typed dictionary assignments with multiple possible keys -Each possible typed-dictionary key must accept the value written by the augmented assignment. +A key that can select fields with different value types must only be assigned a value accepted by +every possible field. ```py from typing import Literal, TypedDict class Payload(TypedDict): - first: int - second: int - -def update(value: Payload, key: Literal["first", "second"]) -> None: - value[key] += 1 + whole: int + fractional: float - # error: [invalid-assignment] +def update(value: Payload, key: Literal["whole", "fractional"]) -> None: # error: [invalid-assignment] value[key] /= 2 ``` -## Inferred collection targets +## Inferred collection entries -Augmented assignments do not yet participate in full-scope collection inference. +Augmented assignments are not yet included when inferring the element type of an unannotated +collection. ```py values = [1] -# TODO: This should widen the inferred element type without reporting an error. +# TODO: Infer `list[float]` instead of rejecting the assignment. # error: [invalid-assignment] values[0] /= 2 ``` diff --git a/crates/ty_python_semantic/resources/mdtest/attributes.md b/crates/ty_python_semantic/resources/mdtest/attributes.md index 7c7396f9df6587..016f1f2c277cd5 100644 --- a/crates/ty_python_semantic/resources/mdtest/attributes.md +++ b/crates/ty_python_semantic/resources/mdtest/attributes.md @@ -259,6 +259,9 @@ reveal_type(c_instance.b) # revealed: int #### Augmented assignments +An augmented assignment contributes its result to the inferred type of an unannotated instance +attribute. + ```py class Weird: def __iadd__(self, other: None) -> str: @@ -269,7 +272,7 @@ class C: self.w = Weird() self.w += None -# TODO: Infer `str` alone, since the initial `Weird` value has been overwritten. +# TODO: Infer only `str`, since the initial `Weird` value has been overwritten. reveal_type(C().w) # revealed: Weird | str ```