From c9dd967c69fb6bfd6754bc1acb0900e8e68076ae 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 a9a84916d4dd1d2030b8d7138827923bc332ddde 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 3ba49720ea034c3d8c14d56b1ef4734c04a1a6b4 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 2869af91852f5b27a0da69739e4bf1c791ebc5bc 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 dd98102ed5b27bd2c82932509e73fa7ca482387e Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 6 Aug 2026 09:21:08 -0400 Subject: [PATCH 5/7] [ty] Infer collection types from augmented subscript stores --- crates/ty_python_core/src/builder.rs | 11 +++ .../resources/mdtest/assignment/augmented.md | 5 +- .../resources/mdtest/bidirectional.md | 98 +++++++++++++++++++ .../src/types/infer/builder.rs | 5 +- .../src/types/infer/builder/subscript.rs | 21 ++-- 5 files changed, 124 insertions(+), 16 deletions(-) diff --git a/crates/ty_python_core/src/builder.rs b/crates/ty_python_core/src/builder.rs index 8602cd638da002..d05c9e3399dba9 100644 --- a/crates/ty_python_core/src/builder.rs +++ b/crates/ty_python_core/src/builder.rs @@ -4528,6 +4528,17 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { } } + // An augmented single-element assignment on the collection object. Slices + // produce another collection and require a separate constraint model. + ruff_python_ast::Stmt::AugAssign(ast::StmtAugAssign { target, .. }) => { + matches!( + target.as_ref(), + ast::Expr::Subscript(ast::ExprSubscript { value, slice, .. }) + if !matches!(slice.as_ref(), ast::Expr::Slice(_)) + && ExpressionNodeKey::from(value) == *use_expression + ) + } + // An annotated assignment assigning the collection object to a new binding. ruff_python_ast::Stmt::AnnAssign(_) => true, diff --git a/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md b/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md index 6e1bd7a247b08d..050d2aaa5e8ffc 100644 --- a/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md +++ b/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md @@ -452,13 +452,12 @@ def update(value: A | B) -> None: ## Inferred collection targets -Augmented assignments do not yet participate in full-scope collection inference. +Augmented subscript assignments contribute their operator result to 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 +reveal_type(values) # revealed: list[float] ``` ## Implicit dunder calls on class objects diff --git a/crates/ty_python_semantic/resources/mdtest/bidirectional.md b/crates/ty_python_semantic/resources/mdtest/bidirectional.md index c03a3df57bf96c..48468b7a5d9700 100644 --- a/crates/ty_python_semantic/resources/mdtest/bidirectional.md +++ b/crates/ty_python_semantic/resources/mdtest/bidirectional.md @@ -2424,6 +2424,104 @@ x24[1] = "b" reveal_type(x24) # revealed: dict[int | str, str | int] ``` +## Augmented subscript collection inference + +An augmented subscript assignment constrains an inferred list using the operator's result, not its +right-hand operand. + +```py +values = [1] +values[0] /= 2 +reveal_type(values) # revealed: list[float] +``` + +Augmented assignments to inferred dictionary entries similarly constrain the value type while +preserving the key type. + +```py +mapping = {"value": 1} +mapping["value"] /= 2 +reveal_type(mapping) # revealed: dict[str, float] +``` + +Custom operators can produce a value unrelated to either the original element or the operand. + +```py +class Updated: + def __add__(self, other: int) -> "Updated": + return self + +class Initial: + def __add__(self, other: int) -> Updated: + return Updated() + +custom = [Initial()] +custom[0] += 1 +reveal_type(custom) # revealed: list[Updated | Initial] +``` + +Augmented subscript assignments also combine with the existing constraints on empty collection +constructors. + +```py +constructed = list() +constructed.append(1) +constructed[0] /= 2 +reveal_type(constructed) # revealed: list[float] + +constructed_mapping = dict() +constructed_mapping["value"] = 1 +constructed_mapping["value"] /= 2 +reveal_type(constructed_mapping) # revealed: dict[str, float] +``` + +The same collection may also appear in the right-hand operand. + +```py +self_referential = [1] +self_referential[0] /= self_referential[0] +reveal_type(self_referential) # revealed: list[float] +``` + +An existing augmented slice assignment must not lose its inferred element type. + +```py +sliced = [1] +sliced[:] += [2] +reveal_type(sliced) # revealed: list[int] + +dynamic_slice = [1] +dynamic_slice[slice(1)] += [2] +reveal_type(dynamic_slice) # revealed: list[int] +``` + +A failed operator does not widen the collection or suppress its original diagnostic. + +```py +invalid = [1] +# error: [unsupported-operator] +invalid[0] += "value" +reveal_type(invalid) # revealed: list[int] +``` + +An invalid index likewise cannot contribute a value constraint. + +```py +invalid_index = [1] +# error: [invalid-argument-type] +invalid_index["value"] /= 2 +reveal_type(invalid_index) # revealed: list[int] +``` + +An explicit annotation still fixes the collection's element type. + +```py +annotated: list[int] = [1] +# error: [invalid-assignment] +annotated[0] /= 2 +reveal_type(annotated) # revealed: list[int] +``` + ## Multi-inference diagnostics Diagnostics unrelated to the type-context are only reported once: diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 15858ccc05352b..bc8ff87f3da7eb 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -4910,7 +4910,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { 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() + // A divergent operator result cannot constrain its own collection: recording it + // would prevent the initial element types from seeding fixed-point inference. + if any_over_type(db, env, result_ty, false, |ty| ty.is_divergent()) + || 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 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..36898883edd41a 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/subscript.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/subscript.rs @@ -1580,10 +1580,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { true, ); - // Record the constraints for the object of the subscript assignment, if the object is an - // unannotated collection initializer. - if is_valid_assignment - && let Some(collection_def) = self.index.unannotated_collection_initializer(object) + // Record constraints even if the write does not match the collection's provisional + // specialization: those constraints may widen the specialization and make the write valid. + if let Some(collection_def) = self.index.unannotated_collection_initializer(object) && let Some((class_literal, _)) = object_ty.class_specialization(db, env) { let identity_instance = @@ -1621,14 +1620,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .infer_and_check_argument_types( ArgumentsIter::synthesized(&ast_arguments), &mut call_arguments, - &mut |builder, (_, expr, tcx)| { - // TODO: The argument types have already been inferred and stored in `call_arguments`. - // However, `object` would have been inferred to a be a collection with `Divergent` - // element types, meaning the type context for a given argument, by which the inferred - // type is keyed, may not be the same as the type context we get here. It is not immediately - // clear how to retrieve those types, and so we just re-infer the argument expressions - // for simplicity. - builder.infer_maybe_standalone_expression(expr, tcx) + &mut |builder, (argument_index, _, tcx)| { + if argument_index == 0 { + infer_slice_ty(builder, tcx) + } else { + infer_rhs_value(builder, tcx) + } }, &mut identity_bindings, TypeContext::default(), From e5946ccb0454155d424f272c298b8faae58d3101 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 6 Aug 2026 09:52:05 -0400 Subject: [PATCH 6/7] [ty] Avoid widening collections from invalid ordinary stores --- .../resources/mdtest/bidirectional.md | 36 +++++++++++++++++++ .../src/types/infer/builder.rs | 3 ++ .../src/types/infer/builder/subscript.rs | 15 ++++++-- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/bidirectional.md b/crates/ty_python_semantic/resources/mdtest/bidirectional.md index 48468b7a5d9700..e36f05d9943a97 100644 --- a/crates/ty_python_semantic/resources/mdtest/bidirectional.md +++ b/crates/ty_python_semantic/resources/mdtest/bidirectional.md @@ -2430,6 +2430,10 @@ An augmented subscript assignment constrains an inferred list using the operator right-hand operand. ```py +integers = [0] +integers[0] += 1.0 +reveal_type(integers) # revealed: list[float] + values = [1] values[0] /= 2 reveal_type(values) # revealed: list[float] @@ -2522,6 +2526,38 @@ annotated[0] /= 2 reveal_type(annotated) # revealed: list[int] ``` +## Augmented subscript inference for nested comprehensions + +Collections nested inside a comprehension do not yet participate in full-scope inference through the +outer collection. + +```py +coverage = {key: [0] for key in ["value"]} +# TODO: Widen the nested list to `list[float]` instead of rejecting the assignment. +# error: [invalid-assignment] +coverage["value"][0] += 1.0 +reveal_type(coverage) # revealed: dict[str, list[int]] +``` + +## Rejected ordinary writes do not constrain collections + +An invalid ordinary subscript assignment must not widen an inferred collection and introduce +additional errors at earlier reads or later returns. + +```py +def accepts_strings(first: str, second: str, values: list[str]) -> None: ... +def example(condition: bool, value: str | None) -> list[str]: + if condition: + values = ["initial"] + ["second"] + else: + values = ["initial"] + + accepts_strings(values[0], values[0], [values[0]]) + # error: [invalid-assignment] + values[0] = value + return values +``` + ## Multi-inference diagnostics Diagnostics unrelated to the type-context are only reported once: diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index bc8ff87f3da7eb..54a9a03c3c643f 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -87,6 +87,7 @@ use crate::types::generics::{ }; use crate::types::infer::builder::named_tuple::NamedTupleKind; use crate::types::infer::builder::paramspec_validation::validate_paramspec_components; +use crate::types::infer::builder::subscript::SubscriptAssignmentKind; use crate::types::infer::{ StatementInference, StatementInferenceInner, StatementInferenceInnerExtra, TypeAndRange, TypeExpressionFlags, infer_statement_types, nearest_enclosing_class, @@ -3242,6 +3243,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.validate_subscript_assignment( subscript_expr, value, + SubscriptAssignmentKind::Ordinary, object_ty, &mut infer_slice_ty, infer_assigned_ty, @@ -4926,6 +4928,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.validate_subscript_assignment( subscript, target, + SubscriptAssignmentKind::Augmented, object_ty, &mut |_, _| slice_ty, &mut |_, _| result_ty, 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 36898883edd41a..b0674e9787a51f 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/subscript.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/subscript.rs @@ -38,6 +38,12 @@ use ty_python_core::place::{PlaceExpr, PlaceExprRef}; use ty_python_core::scope::FileScopeId; use ty_python_core::{SemanticIndex, place_table}; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum SubscriptAssignmentKind { + Ordinary, + Augmented, +} + /// Given a string literal or a union of string literals, return an iterator over the contained /// strings, or `None` if the type is neither. fn string_literal_values<'db>( @@ -1553,6 +1559,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { &mut self, target: &ast::ExprSubscript, rhs_value: &ast::Expr, + assignment_kind: SubscriptAssignmentKind, 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>, @@ -1580,9 +1587,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { true, ); - // Record constraints even if the write does not match the collection's provisional - // specialization: those constraints may widen the specialization and make the write valid. - if let Some(collection_def) = self.index.unannotated_collection_initializer(object) + // An augmented store can initially fail against a provisional specialization and become + // valid after its operator result widens the collection. Rejected ordinary stores must not + // contribute constraints because they would also change unrelated reads and returns. + if (is_valid_assignment || assignment_kind == SubscriptAssignmentKind::Augmented) + && let Some(collection_def) = self.index.unannotated_collection_initializer(object) && let Some((class_literal, _)) = object_ty.class_specialization(db, env) { let identity_instance = From a305723d4fa0ce774a0a1e39908d4324f753ac5b Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 6 Aug 2026 13:39:54 -0400 Subject: [PATCH 7/7] [ty] Unify augmented assignment validation and inference --- .../resources/mdtest/assignment/augmented.md | 52 ++++- .../resources/mdtest/attributes.md | 5 +- .../resources/mdtest/bidirectional.md | 1 + .../src/types/class/static_literal.rs | 5 +- .../src/types/infer/builder.rs | 198 +++++------------- 5 files changed, 113 insertions(+), 148 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md b/crates/ty_python_semantic/resources/mdtest/assignment/augmented.md index 050d2aaa5e8ffc..323d0d7f94c17e 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 subscript assignments contribute their operator result to 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 03e5a1dfca79b4..8467866cf6e9b7 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/resources/mdtest/bidirectional.md b/crates/ty_python_semantic/resources/mdtest/bidirectional.md index e36f05d9943a97..59a072d7d6b867 100644 --- a/crates/ty_python_semantic/resources/mdtest/bidirectional.md +++ b/crates/ty_python_semantic/resources/mdtest/bidirectional.md @@ -2513,6 +2513,7 @@ An invalid index likewise cannot contribute a value constraint. ```py invalid_index = [1] # error: [invalid-argument-type] +# error: [invalid-assignment] invalid_index["value"] /= 2 reveal_type(invalid_index) # revealed: list[int] ``` 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 54a9a03c3c643f..4f47f87fb1de99 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -1466,7 +1466,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 } @@ -1483,11 +1483,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(|| { @@ -1495,20 +1491,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) @@ -4268,7 +4255,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(), @@ -4658,26 +4645,48 @@ 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); + // A divergent result is provisional cycle recovery, not a value that can constrain a + // collection initializer or satisfy a concrete write contract. + if let Ok(result_ty) = self.infer_augment_assignment(assignment) + && !any_over_type( + self.db(), + self.program_environment(), + result_ty, + false, + |ty| ty.is_divergent(), + ) + { + 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, + SubscriptAssignmentKind::Augmented, + 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(), - ); - } } } } @@ -4811,12 +4820,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: _, @@ -4845,98 +4859,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); - - // A divergent operator result cannot constrain its own collection: recording it - // would prevent the initial element types from seeding fixed-point inference. - if any_over_type(db, env, result_ty, false, |ty| ty.is_divergent()) - || 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, - SubscriptAssignmentKind::Augmented, - 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( @@ -12512,7 +12437,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; } @@ -12532,19 +12461,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