Repository: astral-sh/ruff
Pull request #16469: [red-knot] Support unpacking `with` target
Author: login: ericmarkmartin
Labels: name: ty
description: Multi-file analysis & type inference
Created: 2025-03-03T06:27:53Z | Merged: 2025-03-08T02:36:36Z

<!--
Thank you for contributing to Ruff! To help us out with reviewing, please consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title?
- Does this pull request include references to any relevant issues?
-->

## Summary

Resolves astral-sh/ruff#16365

Add support for unpacking `with` statement targets.

## Test Plan

<!-- How was it tested? -->
Added some test cases, alike the ones added by astral-sh/ruff#15058.



Diff:
diff --git a/crates/red_knot_project/tests/check.rs b/crates/red_knot_project/tests/check.rs
index bf95eefe4c8f7..2f3d9b5172a1c 100644
--- a/crates/red_knot_project/tests/check.rs
+++ b/crates/red_knot_project/tests/check.rs
@@ -216,6 +216,17 @@ impl SourceOrderVisitor<'_> for PullTypesVisitor<'_> {
                 self.visit_body(&for_stmt.orelse);
                 return;
             }
+            Stmt::With(with_stmt) => {
+                for item in &with_stmt.items {
+                    if let Some(target) = &item.optional_vars {
+                        self.visit_target(target);
+                    }
+                    self.visit_expr(&item.context_expr);
+                }
+
+                self.visit_body(&with_stmt.body);
+                return;
+            }
             Stmt::AnnAssign(_)
             | Stmt::Return(_)
             | Stmt::Delete(_)
@@ -223,7 +234,6 @@ impl SourceOrderVisitor<'_> for PullTypesVisitor<'_> {
             | Stmt::TypeAlias(_)
             | Stmt::While(_)
             | Stmt::If(_)
-            | Stmt::With(_)
             | Stmt::Match(_)
             | Stmt::Raise(_)
             | Stmt::Try(_)
diff --git a/crates/red_knot_python_semantic/resources/mdtest/attributes.md b/crates/red_knot_python_semantic/resources/mdtest/attributes.md
index d8fb2c83ccb6c..ad37c68329d6b 100644
--- a/crates/red_knot_python_semantic/resources/mdtest/attributes.md
+++ b/crates/red_knot_python_semantic/resources/mdtest/attributes.md
@@ -358,9 +358,25 @@ class C:
 
 c_instance = C()
 
-# TODO: Should be `Unknown | int | None`
-# error: [unresolved-attribute]
-reveal_type(c_instance.x)  # revealed: Unknown
+reveal_type(c_instance.x)  # revealed: Unknown | int | None
+```
+
+#### Attributes defined in `with` statements, but with unpacking
+
+```py
+class ContextManager:
+    def __enter__(self) -> tuple[int | None, int]: ...
+    def __exit__(self, exc_type, exc_value, traceback) -> None: ...
+
+class C:
+    def __init__(self) -> None:
+        with ContextManager() as (self.x, self.y):
+            pass
+
+c_instance = C()
+
+reveal_type(c_instance.x)  # revealed: Unknown | int | None
+reveal_type(c_instance.y)  # revealed: Unknown | int
 ```
 
 #### Attributes defined in comprehensions
diff --git a/crates/red_knot_python_semantic/resources/mdtest/unpacking.md b/crates/red_knot_python_semantic/resources/mdtest/unpacking.md
index f8bd6521b411d..50a7a64388d3b 100644
--- a/crates/red_knot_python_semantic/resources/mdtest/unpacking.md
+++ b/crates/red_knot_python_semantic/resources/mdtest/unpacking.md
@@ -613,3 +613,98 @@ def _(arg: tuple[tuple[int, str], Iterable]):
         reveal_type(a)  # revealed: int | bytes
         reveal_type(b)  # revealed: str | bytes
 ```
+
+## With statement
+
+Unpacking in a `with` statement.
+
+### Same types
+
+```py
+class ContextManager:
+    def __enter__(self) -> tuple[int, int]:
+        return (1, 2)
+
+    def __exit__(self, exc_type, exc_value, traceback) -> None:
+        pass
+
+with ContextManager() as (a, b):
+    reveal_type(a)  # revealed: int
+    reveal_type(b)  # revealed: int
+```
+
+### Mixed types
+
+```py
+class ContextManager:
+    def __enter__(self) -> tuple[int, str]:
+        return (1, "a")
+
+    def __exit__(self, exc_type, exc_value, traceback) -> None:
+        pass
+
+with ContextManager() as (a, b):
+    reveal_type(a)  # revealed: int
+    reveal_type(b)  # revealed: str
+```
+
+### Nested
+
+```py
+class ContextManager:
+    def __enter__(self) -> tuple[int, tuple[str, bytes]]:
+        return (1, ("a", b"bytes"))
+
+    def __exit__(self, exc_type, exc_value, traceback) -> None:
+        pass
+
+with ContextManager() as (a, (b, c)):
+    reveal_type(a)  # revealed: int
+    reveal_type(b)  # revealed: str
+    reveal_type(c)  # revealed: bytes
+```
+
+### Starred expression
+
+```py
+class ContextManager:
+    def __enter__(self) -> tuple[int, int, int]:
+        return (1, 2, 3)
+
+    def __exit__(self, exc_type, exc_value, traceback) -> None:
+        pass
+
+with ContextManager() as (a, *b):
+    reveal_type(a)  # revealed: int
+    # TODO: Should be list[int] once support for assigning to starred expression is added
+    reveal_type(b)  # revealed: @Todo(starred unpacking)
+```
+
+### Unbound context manager expression
+
+```py
+# TODO: should only be one diagnostic
+# error: [unresolved-reference] "Name `nonexistant` used when not defined"
+# error: [unresolved-reference] "Name `nonexistant` used when not defined"
+# error: [unresolved-reference] "Name `nonexistant` used when not defined"
+with nonexistant as (x, y):
+    reveal_type(x)  # revealed: Unknown
+    reveal_type(y)  # revealed: Unknown
+```
+
+### Invalid unpacking
+
+```py
+class ContextManager:
+    def __enter__(self) -> tuple[int, str]:
+        return (1, "a")
+
+    def __exit__(self, *args) -> None:
+        pass
+
+# error: [invalid-assignment] "Not enough values to unpack (expected 3, got 2)"
+with ContextManager() as (a, b, c):
+    reveal_type(a)  # revealed: Unknown
+    reveal_type(b)  # revealed: Unknown
+    reveal_type(c)  # revealed: Unknown
+```
diff --git a/crates/red_knot_python_semantic/resources/mdtest/with/sync.md b/crates/red_knot_python_semantic/resources/mdtest/with/sync.md
index 58e0e6f46697d..2a98cbe19c344 100644
--- a/crates/red_knot_python_semantic/resources/mdtest/with/sync.md
+++ b/crates/red_knot_python_semantic/resources/mdtest/with/sync.md
@@ -45,7 +45,7 @@ def _(flag: bool):
 ```py
 class Manager: ...
 
-# error: [invalid-context-manager] "Object of type `Manager` cannot be used with `with` because it doesn't implement `__enter__` and `__exit__`"
+# error: [invalid-context-manager] "Object of type `Manager` cannot be used with `with` because it does not implement `__enter__` and `__exit__`"
 with Manager():
     ...
 ```
@@ -56,7 +56,7 @@ with Manager():
 class Manager:
     def __exit__(self, exc_tpe, exc_value, traceback): ...
 
-# error: [invalid-context-manager] "Object of type `Manager` cannot be used with `with` because it doesn't implement `__enter__`"
+# error: [invalid-context-manager] "Object of type `Manager` cannot be used with `with` because it does not implement `__enter__`"
 with Manager():
     ...
 ```
@@ -67,7 +67,7 @@ with Manager():
 class Manager:
     def __enter__(self): ...
 
-# error: [invalid-context-manager] "Object of type `Manager` cannot be used with `with` because it doesn't implement `__exit__`"
+# error: [invalid-context-manager] "Object of type `Manager` cannot be used with `with` because it does not implement `__exit__`"
 with Manager():
     ...
 ```
@@ -113,8 +113,7 @@ def _(flag: bool):
     class NotAContextManager: ...
     context_expr = Manager1() if flag else NotAContextManager()
 
-    # error: [invalid-context-manager] "Object of type `Manager1 | NotAContextManager` cannot be used with `with` because the method `__enter__` is possibly unbound"
-    # error: [invalid-context-manager] "Object of type `Manager1 | NotAContextManager` cannot be used with `with` because the method `__exit__` is possibly unbound"
+    # error: [invalid-context-manager] "Object of type `Manager1 | NotAContextManager` cannot be used with `with` because the methods `__enter__` and `__exit__` are possibly unbound"
     with context_expr as f:
         reveal_type(f)  # revealed: str
 ```
diff --git a/crates/red_knot_python_semantic/src/ast_node_ref.rs b/crates/red_knot_python_semantic/src/ast_node_ref.rs
index ad4b03e9afe3d..2ecdb6b752c3c 100644
--- a/crates/red_knot_python_semantic/src/ast_node_ref.rs
+++ b/crates/red_knot_python_semantic/src/ast_node_ref.rs
@@ -45,7 +45,7 @@ pub struct AstNodeRef<T> {
 
 #[allow(unsafe_code)]
 impl<T> AstNodeRef<T> {
-    /// Creates a new `AstNodeRef` that reference `node`. The `parsed` is the [`ParsedModule`] to
+    /// Creates a new `AstNodeRef` that references `node`. The `parsed` is the [`ParsedModule`] to
     /// which the `AstNodeRef` belongs.
     ///
     /// ## Safety
diff --git a/crates/red_knot_python_semantic/src/semantic_index/attribute_assignment.rs b/crates/red_knot_python_semantic/src/semantic_index/attribute_assignment.rs
index 5970ede0d49a7..a5b05db070093 100644
--- a/crates/red_knot_python_semantic/src/semantic_index/attribute_assignment.rs
+++ b/crates/red_knot_python_semantic/src/semantic_index/attribute_assignment.rs
@@ -22,6 +22,10 @@ pub(crate) enum AttributeAssignment<'db> {
     /// `for self.x in <iterable>`.
     Iterable { iterable: Expression<'db> },
 
+    /// An attribute assignment where the expression to be assigned is a context manager, for example
+    /// `with <context_manager> as self.x`.
+    ContextManager { context_manager: Expression<'db> },
+
     /// An attribute assignment where the left-hand side is an unpacking expression,
     /// e.g. `self.x, self.y = <value>`.
     Unpack {
diff --git a/crates/red_knot_python_semantic/src/semantic_index/builder.rs b/crates/red_knot_python_semantic/src/semantic_index/builder.rs
index 74e3494a6d666..2174cb207c483 100644
--- a/crates/red_knot_python_semantic/src/semantic_index/builder.rs
+++ b/crates/red_knot_python_semantic/src/semantic_index/builder.rs
@@ -1032,6 +1032,7 @@ where
                                     self.db,
                                     self.file,
                                     self.current_scope(),
+                                    // SAFETY: `target` belongs to the `self.module` tree
                                     #[allow(unsafe_code)]
                                     unsafe {
                                         AstNodeRef::new(self.module.clone(), target)
@@ -1262,16 +1263,64 @@ where
                 is_async,
                 ..
             }) => {
-                for item in items {
-                    self.visit_expr(&item.context_expr);
-                    if let Some(optional_vars) = item.optional_vars.as_deref() {
-                        self.add_standalone_expression(&item.context_expr);
-                        self.push_assignment(CurrentAssignment::WithItem {
-                            item,
-                            is_async: *is_async,
-                        });
+                for item @ ruff_python_ast::WithItem {
+                    range: _,
+                    context_expr,
+                    optional_vars,
+                } in items
+                {
+                    self.visit_expr(context_expr);
+                    if let Some(optional_vars) = optional_vars.as_deref() {
+                        let context_manager = self.add_standalone_expression(context_expr);
+                        let current_assignment = match optional_vars {
+                            ast::Expr::Tuple(_) | ast::Expr::List(_) => {
+                                Some(CurrentAssignment::WithItem {
+                                    item,
+                                    first: true,
+                                    is_async: *is_async,
+                                    unpack: Some(Unpack::new(
+                                        self.db,
+                                        self.file,
+                                        self.current_scope(),
+                                        // SAFETY: the node `optional_vars` belongs to the `self.module` tree
+                                        #[allow(unsafe_code)]
+                                        unsafe {
+                                            AstNodeRef::new(self.module.clone(), optional_vars)
+                                        },
+                                        UnpackValue::ContextManager(context_manager),
+                                        countme::Count::default(),
+                                    )),
+                                })
+                            }
+                            ast::Expr::Name(_) => Some(CurrentAssignment::WithItem {
+                                item,
+                                is_async: *is_async,
+                                unpack: None,
+                                // `false` is arbitrary here---we don't actually use it other than in the actual unpacks
+                                first: false,
+                            }),
+                            ast::Expr::Attribute(ast::ExprAttribute {
+                                value: object,
+                                attr,
+                                ..
+                            }) => {
+                                self.register_attribute_assignment(
+                                    object,
+                                    attr,
+                                    AttributeAssignment::ContextManager { context_manager },
+                                );
+                                None
+                            }
+                            _ => None,
+                        };
+
+                        if let Some(current_assignment) = current_assignment {
+                            self.push_assignment(current_assignment);
+                        }
                         self.visit_expr(optional_vars);
-                        self.pop_assignment();
+                        if current_assignment.is_some() {
+                            self.pop_assignment();
+                        }
                     }
                 }
                 self.visit_body(body);
@@ -1304,6 +1353,7 @@ where
                             self.db,
                             self.file,
                             self.current_scope(),
+                            // SAFETY: the node `target` belongs to the `self.module` tree
                             #[allow(unsafe_code)]
                             unsafe {
                                 AstNodeRef::new(self.module.clone(), target)
@@ -1631,12 +1681,19 @@ where
                                 },
                             );
                         }
-                        Some(CurrentAssignment::WithItem { item, is_async }) => {
+                        Some(CurrentAssignment::WithItem {
+                            item,
+                            first,
+                            is_async,
+                            unpack,
+                        }) => {
                             self.add_definition(
                                 symbol,
                                 WithItemDefinitionNodeRef {
-                                    node: item,
-                                    target: name_node,
+                                    unpack,
+                                    context_expr: &item.context_expr,
+                                    name: name_node,
+                                    first,
                                     is_async,
                                 },
                             );
@@ -1646,7 +1703,9 @@ where
                 }
 
                 if let Some(
-                    CurrentAssignment::Assign { first, .. } | CurrentAssignment::For { first, .. },
+                    CurrentAssignment::Assign { first, .. }
+                    | CurrentAssignment::For { first, .. }
+                    | CurrentAssignment::WithItem { first, .. },
                 ) = self.current_assignment_mut()
                 {
                     *first = false;
@@ -1826,6 +1885,10 @@ where
                     | CurrentAssignment::For {
                         unpack: Some(unpack),
                         ..
+                    }
+                    | CurrentAssignment::WithItem {
+                        unpack: Some(unpack),
+                        ..
                     },
                 ) = self.current_assignment()
                 {
@@ -1919,7 +1982,9 @@ enum CurrentAssignment<'a> {
     },
     WithItem {
         item: &'a ast::WithItem,
+        first: bool,
         is_async: bool,
+        unpack: Option<Unpack<'a>>,
     },
 }
 
diff --git a/crates/red_knot_python_semantic/src/semantic_index/definition.rs b/crates/red_knot_python_semantic/src/semantic_index/definition.rs
index d41c3ccdb845e..350483225d8e1 100644
--- a/crates/red_knot_python_semantic/src/semantic_index/definition.rs
+++ b/crates/red_knot_python_semantic/src/semantic_index/definition.rs
@@ -201,8 +201,10 @@ pub(crate) struct AssignmentDefinitionNodeRef<'a> {
 
 #[derive(Copy, Clone, Debug)]
 pub(crate) struct WithItemDefinitionNodeRef<'a> {
-    pub(crate) node: &'a ast::WithItem,
-    pub(crate) target: &'a ast::ExprName,
+    pub(crate) unpack: Option<Unpack<'a>>,
+    pub(crate) context_expr: &'a ast::Expr,
+    pub(crate) name: &'a ast::ExprName,
+    pub(crate) first: bool,
     pub(crate) is_async: bool,
 }
 
@@ -323,12 +325,16 @@ impl<'db> DefinitionNodeRef<'db> {
                 DefinitionKind::Parameter(AstNodeRef::new(parsed, parameter))
             }
             DefinitionNodeRef::WithItem(WithItemDefinitionNodeRef {
-                node,
-                target,
+                unpack,
+                context_expr,
+                name,
+                first,
                 is_async,
             }) => DefinitionKind::WithItem(WithItemDefinitionKind {
-                node: AstNodeRef::new(parsed.clone(), node),
-                target: AstNodeRef::new(parsed, target),
+                target: TargetKind::from(unpack),
+                context_expr: AstNodeRef::new(parsed.clone(), context_expr),
+                name: AstNodeRef::new(parsed, name),
+                first,
                 is_async,
             }),
             DefinitionNodeRef::MatchPattern(MatchPatternDefinitionNodeRef {
@@ -394,10 +400,12 @@ impl<'db> DefinitionNodeRef<'db> {
             Self::VariadicKeywordParameter(node) => node.into(),
             Self::Parameter(node) => node.into(),
             Self::WithItem(WithItemDefinitionNodeRef {
-                node: _,
-                target,
+                unpack: _,
+                context_expr: _,
+                first: _,
                 is_async: _,
-            }) => target.into(),
+                name,
+            }) => name.into(),
             Self::MatchPattern(MatchPatternDefinitionNodeRef { identifier, .. }) => {
                 identifier.into()
             }
@@ -467,7 +475,7 @@ pub enum DefinitionKind<'db> {
     VariadicPositionalParameter(AstNodeRef<ast::Parameter>),
     VariadicKeywordParameter(AstNodeRef<ast::Parameter>),
     Parameter(AstNodeRef<ast::ParameterWithDefault>),
-    WithItem(WithItemDefinitionKind),
+    WithItem(WithItemDefinitionKind<'db>),
     MatchPattern(MatchPatternDefinitionKind),
     ExceptHandler(ExceptHandlerDefinitionKind),
     TypeVar(AstNodeRef<ast::TypeParamTypeVar>),
@@ -506,7 +514,7 @@ impl DefinitionKind<'_> {
             DefinitionKind::VariadicPositionalParameter(parameter) => parameter.name.range(),
             DefinitionKind::VariadicKeywordParameter(parameter) => parameter.name.range(),
             DefinitionKind::Parameter(parameter) => parameter.parameter.name.range(),
-            DefinitionKind::WithItem(with_item) => with_item.target().range(),
+            DefinitionKind::WithItem(with_item) => with_item.name().range(),
             DefinitionKind::MatchPattern(match_pattern) => match_pattern.identifier.range(),
             DefinitionKind::ExceptHandler(handler) => handler.node().range(),
             DefinitionKind::TypeVar(type_var) => type_var.name.range(),
@@ -688,19 +696,29 @@ impl<'db> AssignmentDefinitionKind<'db> {
 }
 
 #[derive(Clone, Debug)]
-pub struct WithItemDefinitionKind {
-    node: AstNodeRef<ast::WithItem>,
-    target: AstNodeRef<ast::ExprName>,
+pub struct WithItemDefinitionKind<'db> {
+    target: TargetKind<'db>,
+    context_expr: AstNodeRef<ast::Expr>,
+    name: AstNodeRef<ast::ExprName>,
+    first: bool,
     is_async: bool,
 }
 
-impl WithItemDefinitionKind {
-    pub(crate) fn node(&self) -> &ast::WithItem {
-        self.node.node()
+impl<'db> WithItemDefinitionKind<'db> {
+    pub(crate) fn context_expr(&self) -> &ast::Expr {
+        self.context_expr.node()
     }
 
-    pub(crate) fn target(&self) -> &ast::ExprName {
-        self.target.node()
+    pub(crate) fn target(&self) -> TargetKind<'db> {
+        self.target
+    }
+
+    pub(crate) fn name(&self) -> &ast::ExprName {
+        self.name.node()
+    }
+
+    pub(crate) const fn is_first(&self) -> bool {
+        self.first
     }
 
     pub(crate) const fn is_async(&self) -> bool {
diff --git a/crates/red_knot_python_semantic/src/types.rs b/crates/red_knot_python_semantic/src/types.rs
index a2b1bb4e00421..65065b4e052e4 100644
--- a/crates/red_knot_python_semantic/src/types.rs
+++ b/crates/red_knot_python_semantic/src/types.rs
@@ -4,7 +4,7 @@ use std::str::FromStr;
 use bitflags::bitflags;
 use call::{CallDunderError, CallError};
 use context::InferContext;
-use diagnostic::NOT_ITERABLE;
+use diagnostic::{INVALID_CONTEXT_MANAGER, NOT_ITERABLE};
 use ruff_db::files::File;
 use ruff_python_ast as ast;
 use ruff_python_ast::name::Name;
@@ -2841,6 +2841,52 @@ impl<'db> Type<'db> {
         })
     }
 
+    /// Returns the type bound from a context manager with type `self`.
+    ///
+    /// This method should only be used outside of type checking because it omits any errors.
+    /// For type checking, use [`try_enter`](Self::try_enter) instead.
+    fn enter(self, db: &'db dyn Db) -> Type<'db> {
+        self.try_enter(db)
+            .unwrap_or_else(|err| err.fallback_enter_type(db))
+    }
+
+    /// Given the type of an object that is used as a context manager (i.e. in a `with` statement),
+    /// return the return type of its `__enter__` method, which is bound to any potential targets.
+    ///
+    /// E.g., for the following `with` statement, given the type of `x`, infer the type of `y`:
+    /// ```python
+    /// with x as y:
+    ///     pass
+    /// ```
+    fn try_enter(self, db: &'db dyn Db) -> Result<Type<'db>, ContextManagerError<'db>> {
+        let enter = self.try_call_dunder(db, "__enter__", &CallArguments::none());
+        let exit = self.try_call_dunder(
+            db,
+            "__exit__",
+            &CallArguments::positional([Type::none(db), Type::none(db), Type::none(db)]),
+        );
+
+        // TODO: Make use of Protocols when we support it (the manager be assignable to `contextlib.AbstractContextManager`).
+        let result = match (enter, exit) {
+            (Ok(enter), Ok(_)) => Ok(enter.return_type(db)),
+            (Ok(enter), Err(exit_error)) => Err(ContextManagerErrorKind::Exit {
+                enter_return_type: enter.return_type(db),
+                exit_error,
+            }),
+            // TODO: Use the `exit_ty` to determine if any raised exception is suppressed.
+            (Err(enter_error), Ok(_)) => Err(ContextManagerErrorKind::Enter(enter_error)),
+            (Err(enter_error), Err(exit_error)) => Err(ContextManagerErrorKind::EnterAndExit {
+                enter_error,
+                exit_error,
+            }),
+        };
+
+        result.map_err(|error_kind| ContextManagerError {
+            context_manager_type: self,
+            error_kind,
+        })
+    }
+
     #[must_use]
     pub fn to_instance(&self, db: &'db dyn Db) -> Type<'db> {
         match self {
@@ -3374,6 +3420,135 @@ pub enum TypeVarBoundOrConstraints<'db> {
     Constraints(TupleType<'db>),
 }
 
+/// Error returned if a type is not (or may not be) a context manager.
+#[derive(Debug)]
+struct ContextManagerError<'db> {
+    /// The type of the object that the analysed code attempted to use as a context manager.
+    context_manager_type: Type<'db>,
+
+    /// The precise kind of error encountered when trying to use the type as a context manager.
+    error_kind: ContextManagerErrorKind<'db>,
+}
+
+impl<'db> ContextManagerError<'db> {
+    fn enter_type(&self, db: &'db dyn Db) -> Option<Type<'db>> {
+        self.error_kind.enter_type(db)
+    }
+
+    fn fallback_enter_type(&self, db: &'db dyn Db) -> Type<'db> {
+        self.enter_type(db).unwrap_or(Type::unknown())
+    }
+
+    /// Reports the diagnostic for this error
+    fn report_diagnostic(
+        &self,
+        context: &InferContext<'db>,
+        context_manager_node: ast::AnyNodeRef,
+    ) {
+        self.error_kind
+            .report_diagnostic(context, self.context_manager_type, context_manager_node);
+    }
+}
+
+#[derive(Debug)]
+enum ContextManagerErrorKind<'db> {
+    Exit {
+        enter_return_type: Type<'db>,
+        exit_error: CallDunderError<'db>,
+    },
+    Enter(CallDunderError<'db>),
+    EnterAndExit {
+        enter_error: CallDunderError<'db>,
+        exit_error: CallDunderError<'db>,
+    },
+}
+
+impl<'db> ContextManagerErrorKind<'db> {
+    fn enter_type(&self, db: &'db dyn Db) -> Option<Type<'db>> {
+        match self {
+            ContextManagerErrorKind::Exit {
+                enter_return_type,
+                exit_error: _,
+            } => Some(*enter_return_type),
+            ContextManagerErrorKind::Enter(enter_error)
+            | ContextManagerErrorKind::EnterAndExit {
+                enter_error,
+                exit_error: _,
+            } => match enter_error {
+                CallDunderError::PossiblyUnbound(call_outcome) => {
+                    Some(call_outcome.return_type(db))
+                }
+                CallDunderError::Call(call_error) => call_error.return_type(db),
+                CallDunderError::MethodNotAvailable => None,
+            },
+        }
+    }
+
+    fn report_diagnostic(
+        &self,
+        context: &InferContext<'db>,
+        context_expression_ty: Type<'db>,
+        context_expression_node: ast::AnyNodeRef,
+    ) {
+        let format_call_dunder_error = |call_dunder_error: &CallDunderError<'db>, name: &str| {
+            match call_dunder_error {
+                CallDunderError::MethodNotAvailable => format!("it does not implement `{name}`"),
+                CallDunderError::PossiblyUnbound(_) => {
+                    format!("the method `{name}` is possibly unbound")
+                }
+                // TODO: Use more specific error messages for the different error cases.
+                //  E.g. hint toward the union variant that doesn't correctly implement enter,
+                //  distinguish between a not callable `__enter__` attribute and a wrong signature.
+                CallDunderError::Call(_) => format!("it does not correctly implement `{name}`"),
+            }
+        };
+
+        let format_call_dunder_errors = |error_a: &CallDunderError<'db>,
+                                         name_a: &str,
+                                         error_b: &CallDunderError<'db>,
+                                         name_b: &str| {
+            match (error_a, error_b) {
+                (CallDunderError::PossiblyUnbound(_), CallDunderError::PossiblyUnbound(_)) => {
+                    format!("the methods `{name_a}` and `{name_b}` are possibly unbound")
+                }
+                (CallDunderError::MethodNotAvailable, CallDunderError::MethodNotAvailable) => {
+                    format!("it does not implement `{name_a}` and `{name_b}`")
+                }
+                (CallDunderError::Call(_), CallDunderError::Call(_)) => {
+                    format!("it does not correctly implement `{name_a}` or `{name_b}`")
+                }
+                (_, _) => format!(
+                    "{format_a}, and {format_b}",
+                    format_a = format_call_dunder_error(error_a, name_a),
+                    format_b = format_call_dunder_error(error_b, name_b)
+                ),
+            }
+        };
+
+        let db = context.db();
+
+        let formatted_errors = match self {
+            ContextManagerErrorKind::Exit {
+                enter_return_type: _,
+                exit_error,
+            } => format_call_dunder_error(exit_error, "__exit__"),
+            ContextManagerErrorKind::Enter(enter_error) => {
+                format_call_dunder_error(enter_error, "__enter__")
+            }
+            ContextManagerErrorKind::EnterAndExit {
+                enter_error,
+                exit_error,
+            } => format_call_dunder_errors(enter_error, "__enter__", exit_error, "__exit__"),
+        };
+
+        context.report_lint(&INVALID_CONTEXT_MANAGER, context_expression_node,
+            format_args!("Object of type `{context_expression}` cannot be used with `with` because {formatted_errors}",
+context_expression = context_expression_ty.display(db)
+        ),
+        );
+    }
+}
+
 /// Error returned if a type is not (or may not be) iterable.
 #[derive(Debug)]
 struct IterationError<'db> {
diff --git a/crates/red_knot_python_semantic/src/types/class.rs b/crates/red_knot_python_semantic/src/types/class.rs
index 88111da6170be..04854b7c2e7a5 100644
--- a/crates/red_knot_python_semantic/src/types/class.rs
+++ b/crates/red_knot_python_semantic/src/types/class.rs
@@ -513,6 +513,16 @@ impl<'db> Class<'db> {
 
                     union_of_inferred_types = union_of_inferred_types.add(inferred_ty);
                 }
+                AttributeAssignment::ContextManager { context_manager } => {
+                    // We found an attribute assignment like:
+                    //
+                    //     with <context_manager> as self.name:
+
+                    let context_ty = infer_expression_type(db, *context_manager);
+                    let inferred_ty = context_ty.enter(db);
+
+                    union_of_inferred_types = union_of_inferred_types.add(inferred_ty);
+                }
                 AttributeAssignment::Unpack {
                     attribute_expression_id,
                     unpack,
diff --git a/crates/red_knot_python_semantic/src/types/infer.rs b/crates/red_knot_python_semantic/src/types/infer.rs
index ee57d689964d8..489e4a4789496 100644
--- a/crates/red_knot_python_semantic/src/types/infer.rs
+++ b/crates/red_knot_python_semantic/src/types/infer.rs
@@ -43,7 +43,7 @@ use crate::module_resolver::{file_to_module, resolve_module};
 use crate::semantic_index::ast_ids::{HasScopedExpressionId, HasScopedUseId, ScopedExpressionId};
 use crate::semantic_index::definition::{
     AssignmentDefinitionKind, Definition, DefinitionKind, DefinitionNodeKey,
-    ExceptHandlerDefinitionKind, ForStmtDefinitionKind, TargetKind,
+    ExceptHandlerDefinitionKind, ForStmtDefinitionKind, TargetKind, WithItemDefinitionKind,
 };
 use crate::semantic_index::expression::{Expression, ExpressionKind};
 use crate::semantic_index::semantic_index;
@@ -60,10 +60,10 @@ use crate::types::diagnostic::{
     report_invalid_attribute_assignment, report_unresolved_module, TypeCheckDiagnostics,
     CALL_NON_CALLABLE, CALL_POSSIBLY_UNBOUND_METHOD, CONFLICTING_DECLARATIONS,
     CONFLICTING_METACLASS, CYCLIC_CLASS_DEFINITION, DIVISION_BY_ZERO, DUPLICATE_BASE,
-    INCONSISTENT_MRO, INVALID_ATTRIBUTE_ACCESS, INVALID_BASE, INVALID_CONTEXT_MANAGER,
-    INVALID_DECLARATION, INVALID_PARAMETER_DEFAULT, INVALID_TYPE_FORM,
-    INVALID_TYPE_VARIABLE_CONSTRAINTS, POSSIBLY_UNBOUND_ATTRIBUTE, POSSIBLY_UNBOUND_IMPORT,
-    UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, UNRESOLVED_IMPORT, UNSUPPORTED_OPERATOR,
+    INCONSISTENT_MRO, INVALID_ATTRIBUTE_ACCESS, INVALID_BASE, INVALID_DECLARATION,
+    INVALID_PARAMETER_DEFAULT, INVALID_TYPE_FORM, INVALID_TYPE_VARIABLE_CONSTRAINTS,
+    POSSIBLY_UNBOUND_ATTRIBUTE, POSSIBLY_UNBOUND_IMPORT, UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE,
+    UNRESOLVED_IMPORT, UNSUPPORTED_OPERATOR,
 };
 use crate::types::mro::MroErrorKind;
 use crate::types::unpacker::{UnpackResult, Unpacker};
@@ -839,13 +839,8 @@ impl<'db> TypeInferenceBuilder<'db> {
             DefinitionKind::Parameter(parameter_with_default) => {
                 self.infer_parameter_definition(parameter_with_default, definition);
             }
-            DefinitionKind::WithItem(with_item) => {
-                self.infer_with_item_definition(
-                    with_item.target(),
-                    with_item.node(),
-                    with_item.is_async(),
-                    definition,
-                );
+            DefinitionKind::WithItem(with_item_definition) => {
+                self.infer_with_item_definition(with_item_definition, definition);
             }
             DefinitionKind::MatchPattern(match_pattern) => {
                 self.infer_match_pattern_definition(
@@ -1597,18 +1592,17 @@ impl<'db> TypeInferenceBuilder<'db> {
         } = with_statement;
         for item in items {
             let target = item.optional_vars.as_deref();
-            if let Some(ast::Expr::Name(name)) = target {
-                self.infer_definition(name);
+            if let Some(target) = target {
+                self.infer_target(target, &item.context_expr, |db, ctx_manager_ty| {
+                    // TODO: `infer_with_statement_definition` reports a diagnostic if `ctx_manager_ty` isn't a context manager
+                    //  but only if the target is a name. We should report a diagnostic here if the target isn't a name:
+                    //  `with not_context_manager as a.x: ...
+                    ctx_manager_ty.enter(db)
+                });
             } else {
-                // TODO infer definitions in unpacking assignment
-
                 // Call into the context expression inference to validate that it evaluates
                 // to a valid context manager.
-                let context_expression_ty = if target.is_some() {
-                    self.infer_standalone_expression(&item.context_expr)
-                } else {
-                    self.infer_expression(&item.context_expr)
-                };
+                let context_expression_ty = self.infer_expression(&item.context_expr);
                 self.infer_context_expression(&item.context_expr, context_expression_ty, *is_async);
                 self.infer_optional_expression(target);
             }
@@ -1619,24 +1613,36 @@ impl<'db> TypeInferenceBuilder<'db> {
 
     fn infer_with_item_definition(
         &mut self,
-        target: &ast::ExprName,
-        with_item: &ast::WithItem,
-        is_async: bool,
+        with_item: &WithItemDefinitionKind<'db>,
         definition: Definition<'db>,
     ) {
-        self.infer_standalone_expression(&with_item.context_expr);
+        let context_expr = with_item.context_expr();
+        let name = with_item.name();
 
-        let target_ty = self.infer_context_expression(
-            &with_item.context_expr,
-            self.expression_type(&with_item.context_expr),
-            is_async,
-        );
+        let context_expr_ty = self.infer_standalone_expression(context_expr);
 
-        self.types.expressions.insert(
-            target.scoped_expression_id(self.db(), self.scope()),
-            target_ty,
-        );
-        self.add_binding(target.into(), definition, target_ty);
+        let target_ty = if with_item.is_async() {
+            todo_type!("async `with` statement")
+        } else {
+            match with_item.target() {
+                TargetKind::Sequence(unpack) => {
+                    let unpacked = infer_unpack_types(self.db(), unpack);
+                    let name_ast_id = name.scoped_expression_id(self.db(), self.scope());
+                    if with_item.is_first() {
+                        self.context.extend(unpacked);
+                    }
+                    unpacked.expression_type(name_ast_id)
+                }
+                TargetKind::Name => self.infer_context_expression(
+                    context_expr,
+                    context_expr_ty,
+                    with_item.is_async(),
+                ),
+            }
+        };
+
+        self.store_expression_type(name, target_ty);
+        self.add_binding(name.into(), definition, target_ty);
     }
 
     /// Infers the type of a context expression (`with expr`) and returns the target's type
@@ -1656,120 +1662,12 @@ impl<'db> TypeInferenceBuilder<'db> {
             return todo_type!("async `with` statement");
         }
 
-        let context_manager_ty = context_expression_ty.to_meta_type(self.db());
-
-        let enter = context_manager_ty.member(self.db(), "__enter__").symbol;
-        let exit = context_manager_ty.member(self.db(), "__exit__").symbol;
-
-        // TODO: Make use of Protocols when we support it (the manager be assignable to `contextlib.AbstractContextManager`).
-        match (enter, exit) {
-            (Symbol::Unbound, Symbol::Unbound) => {
-                self.context.report_lint(
-                    &INVALID_CONTEXT_MANAGER,
-                    context_expression,
-                    format_args!(
-                        "Object of type `{}` cannot be used with `with` because it doesn't implement `__enter__` and `__exit__`",
-                        context_expression_ty.display(self.db())
-                    ),
-                );
-                Type::unknown()
-            }
-            (Symbol::Unbound, _) => {
-                self.context.report_lint(
-                    &INVALID_CONTEXT_MANAGER,
-                    context_expression,
-                    format_args!(
-                        "Object of type `{}` cannot be used with `with` because it doesn't implement `__enter__`",
-                        context_expression_ty.display(self.db())
-                    ),
-                );
-                Type::unknown()
-            }
-            (Symbol::Type(enter_ty, enter_boundness), exit) => {
-                if enter_boundness == Boundness::PossiblyUnbound {
-                    self.context.report_lint(
-                        &INVALID_CONTEXT_MANAGER,
-                        context_expression,
-                        format_args!(
-                            "Object of type `{context_expression}` cannot be used with `with` because the method `__enter__` is possibly unbound",
-                            context_expression = context_expression_ty.display(self.db()),
-                        ),
-                    );
-                }
-
-                let target_ty = enter_ty
-                    .try_call(self.db(), &CallArguments::positional([context_expression_ty]))
-                    .map(|outcome| outcome.return_type(self.db()))
-                    .unwrap_or_else(|err|  {
-                        // TODO: Use more specific error messages for the different error cases.
-                        //  E.g. hint toward the union variant that doesn't correctly implement enter,
-                        //  distinguish between a not callable `__enter__` attribute and a wrong signature.
-                        self.context.report_lint(
-                            &INVALID_CONTEXT_MANAGER,
-                            context_expression,
-                            format_args!("
-                                Object of type `{context_expression}` cannot be used with `with` because it does not correctly implement `__enter__`",
-                                context_expression = context_expression_ty.display(self.db()),
-                            ),
-                        );
-                        err.fallback_return_type(self.db())
-                    });
-
-                match exit {
-                    Symbol::Unbound => {
-                        self.context.report_lint(
-                            &INVALID_CONTEXT_MANAGER,
-                            context_expression,
-                            format_args!(
-                                "Object of type `{}` cannot be used with `with` because it doesn't implement `__exit__`",
-                                context_expression_ty.display(self.db())
-                            ),
-                        );
-                    }
-                    Symbol::Type(exit_ty, exit_boundness) => {
-                        // TODO: Use the `exit_ty` to determine if any raised exception is suppressed.
-
-                        if exit_boundness == Boundness::PossiblyUnbound {
-                            self.context.report_lint(
-                                &INVALID_CONTEXT_MANAGER,
-                                context_expression,
-                                format_args!(
-                                    "Object of type `{context_expression}` cannot be used with `with` because the method `__exit__` is possibly unbound",
-                                    context_expression = context_expression_ty.display(self.db()),
-                                ),
-                            );
-                        }
-
-                        if exit_ty
-                            .try_call(
-                                self.db(),
-                                &CallArguments::positional([
-                                    context_manager_ty,
-                                    Type::none(self.db()),
-                                    Type::none(self.db()),
-                                    Type::none(self.db()),
-                                ]),
-                            )
-                            .is_err()
-                        {
-                            // TODO: Use more specific error messages for the different error cases.
-                            //  E.g. hint toward the union variant that doesn't correctly implement enter,
-                            //  distinguish between a not callable `__exit__` attribute and a wrong signature.
-                            self.context.report_lint(
-                                &INVALID_CONTEXT_MANAGER,
-                                context_expression,
-                                format_args!(
-                                    "Object of type `{context_expression}` cannot be used with `with` because it does not correctly implement `__exit__`",
-                                    context_expression = context_expression_ty.display(self.db()),
-                                ),
-                            );
-                        }
-                    }
-                }
-
-                target_ty
-            }
-        }
+        context_expression_ty
+            .try_enter(self.db())
+            .unwrap_or_else(|err| {
+                err.report_diagnostic(&self.context, context_expression.into());
+                err.fallback_enter_type(self.db())
+            })
     }
 
     fn infer_except_handler_definition(
diff --git a/crates/red_knot_python_semantic/src/types/unpacker.rs b/crates/red_knot_python_semantic/src/types/unpacker.rs
index 1abe02020fc6b..224e34fffdd79 100644
--- a/crates/red_knot_python_semantic/src/types/unpacker.rs
+++ b/crates/red_knot_python_semantic/src/types/unpacker.rs
@@ -42,26 +42,28 @@ impl<'db> Unpacker<'db> {
             "Unpacking target must be a list or tuple expression"
         );
 
-        let mut value_ty = infer_expression_types(self.db(), value.expression())
+        let value_ty = infer_expression_types(self.db(), value.expression())
             .expression_type(value.scoped_expression_id(self.db(), self.scope));
 
-        if value.is_assign()
-            && self.context.in_stub()
-            && value
-                .expression()
-                .node_ref(self.db())
-                .is_ellipsis_literal_expr()
-        {
-            value_ty = Type::unknown();
-        }
-        if value.is_iterable() {
-            // If the value is an iterable, then the type that needs to be unpacked is the iterator
-            // type.
-            value_ty = value_ty.try_iterate(self.db()).unwrap_or_else(|err| {
+        let value_ty = match value {
+            UnpackValue::Assign(expression) => {
+                if self.context.in_stub()
+                    && expression.node_ref(self.db()).is_ellipsis_literal_expr()
+                {
+                    Type::unknown()
+                } else {
+                    value_ty
+                }
+            }
+            UnpackValue::Iterable(_) => value_ty.try_iterate(self.db()).unwrap_or_else(|err| {
                 err.report_diagnostic(&self.context, value.as_any_node_ref(self.db()));
                 err.fallback_element_type(self.db())
-            });
-        }
+            }),
+            UnpackValue::ContextManager(_) => value_ty.try_enter(self.db()).unwrap_or_else(|err| {
+                err.report_diagnostic(&self.context, value.as_any_node_ref(self.db()));
+                err.fallback_enter_type(self.db())
+            }),
+        };
 
         self.unpack_inner(target, value.as_any_node_ref(self.db()), value_ty);
     }
diff --git a/crates/red_knot_python_semantic/src/unpack.rs b/crates/red_knot_python_semantic/src/unpack.rs
index 3738fe9807196..00736e7110ea4 100644
--- a/crates/red_knot_python_semantic/src/unpack.rs
+++ b/crates/red_knot_python_semantic/src/unpack.rs
@@ -63,25 +63,19 @@ impl<'db> Unpack<'db> {
 pub(crate) enum UnpackValue<'db> {
     /// An iterable expression like the one in a `for` loop or a comprehension.
     Iterable(Expression<'db>),
+    /// An context manager expression like the one in a `with` statement.
+    ContextManager(Expression<'db>),
     /// An expression that is being assigned to a target.
     Assign(Expression<'db>),
 }
 
 impl<'db> UnpackValue<'db> {
-    /// Returns `true` if the value is an iterable expression.
-    pub(crate) const fn is_iterable(self) -> bool {
-        matches!(self, UnpackValue::Iterable(_))
-    }
-
-    /// Returns `true` if the value is being assigned to a target.
-    pub(crate) const fn is_assign(self) -> bool {
-        matches!(self, UnpackValue::Assign(_))
-    }
-
     /// Returns the underlying [`Expression`] that is being unpacked.
     pub(crate) const fn expression(self) -> Expression<'db> {
         match self {
-            UnpackValue::Assign(expr) | UnpackValue::Iterable(expr) => expr,
+            UnpackValue::Assign(expr)
+            | UnpackValue::Iterable(expr)
+            | UnpackValue::ContextManager(expr) => expr,
         }
     }
 


Reviews:
user: login: ericmarkmartin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T06:32:55Z
user: login: ericmarkmartin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T06:33:59Z
user: login: ericmarkmartin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T03:58:47Z
user: login: MichaReiser
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T07:38:53Z
user: login: carljm
body: Looks great, very impressive first PR!

I didn't get a chance to explore the (lack of) duplicated diagnostics that we discussed on Discord, will try to look at that later.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T00:55:35Z
user: login: dhruvmanila
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-07T09:06:33Z
user: login: ericmarkmartin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T23:03:20Z
user: login: carljm
body: Looks great, thank you! Just one failing test with a trivial fix, so I'll push that and then merge.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-08T02:32:49Z

Review comments:
diff_hunk: @@ -1576,11 +1571,7 @@ impl<'db> TypeInferenceBuilder<'db> {
 
                 // Call into the context expression inference to validate that it evaluates
                 // to a valid context manager.
-                let context_expression_ty = if target.is_some() {
-                    self.infer_standalone_expression(&item.context_expr)
-                } else {
-                    self.infer_expression(&item.context_expr)
-                };
+                let context_expression_ty = self.infer_standalone_expression(&item.context_expr);
path: crates/red_knot_python_semantic/src/types/infer.rs
user: login: ericmarkmartin
body: I changed this because tests were panicking when using `infer_expression` in the else branch. Not sure I totally understand what happened here, but probably had to do with my changing the usage of `infer_context_expression` such that types are being inferred earlier?
created_at: 2025-03-03T06:32:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -63,6 +63,11 @@ impl<'db> Unpacker<'db> {
             });
         }
 
+        if value.is_context_manager() {
path: crates/red_knot_python_semantic/src/types/unpacker.rs
user: login: ericmarkmartin
body: I almost missed this because it's not a match and the compiler didn't catch me. Would folks be okay with my refactoring it?
created_at: 2025-03-03T06:33:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2951,6 +2985,137 @@ pub enum TypeVarBoundOrConstraints<'db> {
     Constraints(TupleType<'db>),
 }
 
+/// Error returned if a type is not (or may not be) a context manager.
+#[derive(Debug)]
+struct ContextManagerError<'db> {
+    /// The type of the object that the analysed code attempted to use as a context manager.
+    context_manager_type: Type<'db>,
+
+    /// The precise kind of error encountered when trying to use the type as a context manager.
+    error_kind: ContextManagerErrorKind<'db>,
+}
+
+impl<'db> ContextManagerError<'db> {
+    fn enter_type(&self, db: &'db dyn Db) -> Option<Type<'db>> {
+        self.error_kind.enter_type(db)
+    }
+
+    fn fallback_enter_type(&self, db: &'db dyn Db) -> Type<'db> {
+        self.enter_type(db).unwrap_or(Type::unknown())
+    }
+
+    /// Reports the diagnostic for this error
+    fn report_diagnostic(
+        &self,
+        context: &InferContext<'db>,
+        context_manager_node: ast::AnyNodeRef,
+    ) {
+        self.error_kind
+            .report_diagnostic(context, self.context_manager_type, context_manager_node);
+    }
+}
+
+#[derive(Debug)]
+enum ContextManagerErrorKind<'db> {
+    Exit {
+        enter_ret_ty: Type<'db>,
+        exit_error: CallDunderError<'db>,
+    },
+    Enter(CallDunderError<'db>),
+    EnterAndExit {
+        enter_error: CallDunderError<'db>,
+        exit_error: CallDunderError<'db>,
+    },
+}
+
+impl<'db> ContextManagerErrorKind<'db> {
+    fn enter_type(&self, db: &'db dyn Db) -> Option<Type<'db>> {
+        match self {
+            ContextManagerErrorKind::Exit {
+                enter_ret_ty,
+                exit_error: _,
+            } => Some(*enter_ret_ty),
+            ContextManagerErrorKind::Enter(enter_error)
+            | ContextManagerErrorKind::EnterAndExit {
+                enter_error,
+                exit_error: _,
+            } => match enter_error {
+                CallDunderError::PossiblyUnbound(call_outcome) => {
+                    Some(call_outcome.return_type(db))
+                }
+                CallDunderError::Call(call_error) => call_error.return_type(db),
+                CallDunderError::MethodNotAvailable => None,
+            },
+        }
+    }
+
+    fn format_call_dunder_error(call_dunder_error: &CallDunderError<'db>, name: &str) -> String {
+        match call_dunder_error {
+            CallDunderError::MethodNotAvailable => format!("it doesn't implement `{name}`"),
+            CallDunderError::PossiblyUnbound(_) => {
+                format!("the method `{name}` is possibly unbound")
+            }
+            // TODO: Use more specific error messages for the different error cases.
+            //  E.g. hint toward the union variant that doesn't correctly implement enter,
+            //  distinguish between a not callable `__enter__` attribute and a wrong signature.
+            CallDunderError::Call(_) => format!("it does not correctly implement `{name}`"),
+        }
+    }
+
+    fn format_call_dunder_errors(
+        error_a: &CallDunderError<'db>,
+        name_a: &str,
+        error_b: &CallDunderError<'db>,
+        name_b: &str,
+    ) -> String {
+        match (error_a, error_b) {
+            (CallDunderError::PossiblyUnbound(_), CallDunderError::PossiblyUnbound(_)) => {
+                format!("the methods `{name_a}` and `{name_b}` are possibly unbound")
+            }
+            (CallDunderError::MethodNotAvailable, CallDunderError::MethodNotAvailable) => {
+                format!("it doesn't implement `{name_a}` and `{name_b}`")
path: crates/red_knot_python_semantic/src/types.rs
user: login: ericmarkmartin
body: very small nit: we should probably be consistent with respect to "doesn't" vs "does not"
created_at: 2025-03-05T03:58:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1262,16 +1262,64 @@ where
                 is_async,
                 ..
             }) => {
-                for item in items {
-                    self.visit_expr(&item.context_expr);
-                    if let Some(optional_vars) = item.optional_vars.as_deref() {
-                        self.add_standalone_expression(&item.context_expr);
-                        self.push_assignment(CurrentAssignment::WithItem {
-                            item,
-                            is_async: *is_async,
-                        });
+                for item @ ruff_python_ast::WithItem {
+                    range: _,
+                    context_expr,
+                    optional_vars,
+                } in items
+                {
+                    let context_manager = self.add_standalone_expression(context_expr);
+                    self.visit_expr(context_expr);
+                    if let Some(optional_vars) = optional_vars.as_deref() {
+                        self.add_standalone_expression(context_expr);
+                        let current_assignment = match optional_vars {
+                            ast::Expr::Tuple(_) | ast::Expr::List(_) => {
+                                Some(CurrentAssignment::WithItem {
+                                    item,
+                                    first: true,
+                                    is_async: *is_async,
+                                    unpack: Some(Unpack::new(
+                                        self.db,
+                                        self.file,
+                                        self.current_scope(),
+                                        #[allow(unsafe_code)]
+                                        unsafe {
+                                            AstNodeRef::new(self.module.clone(), optional_vars)
+                                        },
+                                        UnpackValue::ContextManager(context_manager),
+                                        countme::Count::default(),
+                                    )),
+                                })
+                            }
+                            ast::Expr::Name(_) => Some(CurrentAssignment::WithItem {
+                                item,
+                                is_async: *is_async,
+                                unpack: None,
+                                // `false` is arbitrary here---we don't actually use it other than in the actual unpacks
+                                first: false,
+                            }),
+                            ast::Expr::Attribute(ast::ExprAttribute {
+                                value: object,
+                                attr,
+                                ..
+                            }) => {
+                                self.register_attribute_assignment(
+                                    object,
+                                    attr,
+                                    AttributeAssignment::ContextManager { context_manager },
+                                );
+                                None
+                            }
path: crates/red_knot_python_semantic/src/semantic_index/builder.rs
user: login: MichaReiser
body: Nit: This code seems to be the same as for `for` targets with the exception of `UnpackValue::ContextManager`, `AttributeAssignment::ContextManager`. Would it be possible to reuse some more code by having a shared method that takes an `UnpackKind` argument that then constructs the `UnpackValue`, the `AttributeAssignment`
created_at: 2025-03-05T07:36:18Z
author_association: MEMBER
diff_hunk: @@ -356,9 +356,7 @@ class C:
 
 c_instance = C()
 
-# TODO: Should be `Unknown | int | None`
-# error: [unresolved-attribute]
-reveal_type(c_instance.x)  # revealed: Unknown
+reveal_type(c_instance.x)  # revealed: Unknown | int | None
path: crates/red_knot_python_semantic/resources/mdtest/attributes.md
user: login: carljm
body: We should also have a test similar to this, but with code like `with ContextManager() as (self.x, self.y):`. I think this would catch a change we are missing in the `ast::Expr::Attribute` branch of `SemanticIndexBuilder::visit_expr`, where we match on `Assign` and `For` unpacks, but also need to match on `With`.

Bonus points if we can find a way to make this mistake harder to make in future, e.g. with an `UnpackKind` enum that that code matches on.
created_at: 2025-03-06T00:05:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1262,16 +1262,64 @@ where
                 is_async,
                 ..
             }) => {
-                for item in items {
-                    self.visit_expr(&item.context_expr);
-                    if let Some(optional_vars) = item.optional_vars.as_deref() {
-                        self.add_standalone_expression(&item.context_expr);
-                        self.push_assignment(CurrentAssignment::WithItem {
-                            item,
-                            is_async: *is_async,
-                        });
+                for item @ ruff_python_ast::WithItem {
+                    range: _,
+                    context_expr,
+                    optional_vars,
+                } in items
+                {
+                    let context_manager = self.add_standalone_expression(context_expr);
+                    self.visit_expr(context_expr);
+                    if let Some(optional_vars) = optional_vars.as_deref() {
+                        self.add_standalone_expression(context_expr);
+                        let current_assignment = match optional_vars {
+                            ast::Expr::Tuple(_) | ast::Expr::List(_) => {
+                                Some(CurrentAssignment::WithItem {
+                                    item,
+                                    first: true,
+                                    is_async: *is_async,
+                                    unpack: Some(Unpack::new(
+                                        self.db,
+                                        self.file,
+                                        self.current_scope(),
+                                        #[allow(unsafe_code)]
+                                        unsafe {
+                                            AstNodeRef::new(self.module.clone(), optional_vars)
+                                        },
+                                        UnpackValue::ContextManager(context_manager),
+                                        countme::Count::default(),
+                                    )),
+                                })
+                            }
+                            ast::Expr::Name(_) => Some(CurrentAssignment::WithItem {
+                                item,
+                                is_async: *is_async,
+                                unpack: None,
+                                // `false` is arbitrary here---we don't actually use it other than in the actual unpacks
+                                first: false,
+                            }),
+                            ast::Expr::Attribute(ast::ExprAttribute {
+                                value: object,
+                                attr,
+                                ..
+                            }) => {
+                                self.register_attribute_assignment(
+                                    object,
+                                    attr,
+                                    AttributeAssignment::ContextManager { context_manager },
+                                );
+                                None
+                            }
path: crates/red_knot_python_semantic/src/semantic_index/builder.rs
user: login: carljm
body: `Assign` has quite similar code as well, they should probably all three share a helper.

At the very least we should extract repetition around creation of the `Unpack`, things like the unsafe node-ref creation and the `countme::Count::default()` feel like details/boilerplate that don't belong at this level of abstraction and shouldn't be repeated for each node kind.

That said, I'd also be open to landing this PR with some duplication if we get all the behavior correct, and then doing the refactor as a separate PR for ease of review.
created_at: 2025-03-06T00:06:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1262,16 +1262,64 @@ where
                 is_async,
                 ..
             }) => {
-                for item in items {
-                    self.visit_expr(&item.context_expr);
-                    if let Some(optional_vars) = item.optional_vars.as_deref() {
-                        self.add_standalone_expression(&item.context_expr);
-                        self.push_assignment(CurrentAssignment::WithItem {
-                            item,
-                            is_async: *is_async,
-                        });
+                for item @ ruff_python_ast::WithItem {
+                    range: _,
+                    context_expr,
+                    optional_vars,
+                } in items
+                {
+                    let context_manager = self.add_standalone_expression(context_expr);
+                    self.visit_expr(context_expr);
+                    if let Some(optional_vars) = optional_vars.as_deref() {
+                        self.add_standalone_expression(context_expr);
+                        let current_assignment = match optional_vars {
+                            ast::Expr::Tuple(_) | ast::Expr::List(_) => {
+                                Some(CurrentAssignment::WithItem {
+                                    item,
+                                    first: true,
+                                    is_async: *is_async,
+                                    unpack: Some(Unpack::new(
+                                        self.db,
+                                        self.file,
+                                        self.current_scope(),
+                                        #[allow(unsafe_code)]
+                                        unsafe {
+                                            AstNodeRef::new(self.module.clone(), optional_vars)
+                                        },
path: crates/red_knot_python_semantic/src/semantic_index/builder.rs
user: login: carljm
body: This should really have a comment `// SAFETY: the node `optional_vars` is part of the `self.module` tree` or similar. But so should the same code in the `For` and `Assign` versions.
created_at: 2025-03-06T00:09:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1262,16 +1262,64 @@ where
                 is_async,
                 ..
             }) => {
-                for item in items {
-                    self.visit_expr(&item.context_expr);
-                    if let Some(optional_vars) = item.optional_vars.as_deref() {
-                        self.add_standalone_expression(&item.context_expr);
-                        self.push_assignment(CurrentAssignment::WithItem {
-                            item,
-                            is_async: *is_async,
-                        });
+                for item @ ruff_python_ast::WithItem {
+                    range: _,
+                    context_expr,
+                    optional_vars,
+                } in items
+                {
+                    let context_manager = self.add_standalone_expression(context_expr);
+                    self.visit_expr(context_expr);
+                    if let Some(optional_vars) = optional_vars.as_deref() {
+                        self.add_standalone_expression(context_expr);
+                        let current_assignment = match optional_vars {
+                            ast::Expr::Tuple(_) | ast::Expr::List(_) => {
+                                Some(CurrentAssignment::WithItem {
+                                    item,
+                                    first: true,
+                                    is_async: *is_async,
+                                    unpack: Some(Unpack::new(
+                                        self.db,
+                                        self.file,
+                                        self.current_scope(),
+                                        #[allow(unsafe_code)]
+                                        unsafe {
+                                            AstNodeRef::new(self.module.clone(), optional_vars)
+                                        },
+                                        UnpackValue::ContextManager(context_manager),
+                                        countme::Count::default(),
+                                    )),
+                                })
+                            }
+                            ast::Expr::Name(_) => Some(CurrentAssignment::WithItem {
+                                item,
+                                is_async: *is_async,
+                                unpack: None,
+                                // `false` is arbitrary here---we don't actually use it other than in the actual unpacks
+                                first: false,
path: crates/red_knot_python_semantic/src/semantic_index/builder.rs
user: login: carljm
body: Small preference for going with arbitrary `true` over arbitrary `false` here, since it is technically still the first (of one).

But this change would also apply to the same code in `For` and `Assign`, so should be made in whichever PR we refactor for better code reuse.
created_at: 2025-03-06T00:13:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -201,8 +201,11 @@ pub(crate) struct AssignmentDefinitionNodeRef<'a> {
 
 #[derive(Copy, Clone, Debug)]
 pub(crate) struct WithItemDefinitionNodeRef<'a> {
-    pub(crate) node: &'a ast::WithItem,
-    pub(crate) target: &'a ast::ExprName,
+    // outer Option is whether we bind the expression or not, inner is whether it's unpacked
path: crates/red_knot_python_semantic/src/semantic_index/definition.rs
user: login: carljm
body: Is this comment obsolete? There's only one Option here
created_at: 2025-03-06T00:14:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2951,6 +2985,137 @@ pub enum TypeVarBoundOrConstraints<'db> {
     Constraints(TupleType<'db>),
 }
 
+/// Error returned if a type is not (or may not be) a context manager.
+#[derive(Debug)]
+struct ContextManagerError<'db> {
+    /// The type of the object that the analysed code attempted to use as a context manager.
+    context_manager_type: Type<'db>,
+
+    /// The precise kind of error encountered when trying to use the type as a context manager.
+    error_kind: ContextManagerErrorKind<'db>,
+}
+
+impl<'db> ContextManagerError<'db> {
+    fn enter_type(&self, db: &'db dyn Db) -> Option<Type<'db>> {
+        self.error_kind.enter_type(db)
+    }
+
+    fn fallback_enter_type(&self, db: &'db dyn Db) -> Type<'db> {
+        self.enter_type(db).unwrap_or(Type::unknown())
+    }
+
+    /// Reports the diagnostic for this error
+    fn report_diagnostic(
+        &self,
+        context: &InferContext<'db>,
+        context_manager_node: ast::AnyNodeRef,
+    ) {
+        self.error_kind
+            .report_diagnostic(context, self.context_manager_type, context_manager_node);
+    }
+}
+
+#[derive(Debug)]
+enum ContextManagerErrorKind<'db> {
+    Exit {
+        enter_ret_ty: Type<'db>,
+        exit_error: CallDunderError<'db>,
+    },
+    Enter(CallDunderError<'db>),
+    EnterAndExit {
+        enter_error: CallDunderError<'db>,
+        exit_error: CallDunderError<'db>,
+    },
+}
+
+impl<'db> ContextManagerErrorKind<'db> {
+    fn enter_type(&self, db: &'db dyn Db) -> Option<Type<'db>> {
+        match self {
+            ContextManagerErrorKind::Exit {
+                enter_ret_ty,
+                exit_error: _,
+            } => Some(*enter_ret_ty),
+            ContextManagerErrorKind::Enter(enter_error)
+            | ContextManagerErrorKind::EnterAndExit {
+                enter_error,
+                exit_error: _,
+            } => match enter_error {
+                CallDunderError::PossiblyUnbound(call_outcome) => {
+                    Some(call_outcome.return_type(db))
+                }
+                CallDunderError::Call(call_error) => call_error.return_type(db),
+                CallDunderError::MethodNotAvailable => None,
+            },
+        }
+    }
+
+    fn format_call_dunder_error(call_dunder_error: &CallDunderError<'db>, name: &str) -> String {
+        match call_dunder_error {
+            CallDunderError::MethodNotAvailable => format!("it doesn't implement `{name}`"),
+            CallDunderError::PossiblyUnbound(_) => {
+                format!("the method `{name}` is possibly unbound")
+            }
+            // TODO: Use more specific error messages for the different error cases.
+            //  E.g. hint toward the union variant that doesn't correctly implement enter,
+            //  distinguish between a not callable `__enter__` attribute and a wrong signature.
+            CallDunderError::Call(_) => format!("it does not correctly implement `{name}`"),
+        }
+    }
+
+    fn format_call_dunder_errors(
+        error_a: &CallDunderError<'db>,
+        name_a: &str,
+        error_b: &CallDunderError<'db>,
+        name_b: &str,
+    ) -> String {
+        match (error_a, error_b) {
+            (CallDunderError::PossiblyUnbound(_), CallDunderError::PossiblyUnbound(_)) => {
+                format!("the methods `{name_a}` and `{name_b}` are possibly unbound")
+            }
+            (CallDunderError::MethodNotAvailable, CallDunderError::MethodNotAvailable) => {
+                format!("it doesn't implement `{name_a}` and `{name_b}`")
path: crates/red_knot_python_semantic/src/types.rs
user: login: carljm
body: I would go with "does not"
created_at: 2025-03-06T00:30:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2426,6 +2426,40 @@ impl<'db> Type<'db> {
         })
     }
 
+    fn enter(self, db: &'db dyn Db) -> Type<'db> {
+        self.try_enter(db)
+            .unwrap_or_else(|err| err.fallback_enter_type(db))
+    }
+
path: crates/red_knot_python_semantic/src/types.rs
user: login: carljm
body: We should be quite careful about methods like these, because it means the path of least resistance for a caller is to ignore errors entirely. If we provide this, it should have a warning doc comment, like `Type::bool` and `Type::iterate` have.
created_at: 2025-03-06T00:33:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2426,6 +2426,40 @@ impl<'db> Type<'db> {
         })
     }
 
+    fn enter(self, db: &'db dyn Db) -> Type<'db> {
+        self.try_enter(db)
+            .unwrap_or_else(|err| err.fallback_enter_type(db))
+    }
+
+    fn try_enter(self, db: &'db dyn Db) -> Result<Type<'db>, ContextManagerError<'db>> {
path: crates/red_knot_python_semantic/src/types.rs
user: login: carljm
body: Doc comment for this method wouldn't hurt; similar to `try_iterate`, `try_bool`, etc.
created_at: 2025-03-06T00:36:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -578,3 +578,69 @@ def _(arg: tuple[tuple[int, str], Iterable]):
         reveal_type(a)  # revealed: int | bytes
         reveal_type(b)  # revealed: str | bytes
 ```
+
+## With statement
path: crates/red_knot_python_semantic/resources/mdtest/unpacking.md
user: login: carljm
body: How about a test where evaluating the context_expr itself emits a diagnostic?
created_at: 2025-03-06T00:48:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1576,11 +1571,7 @@ impl<'db> TypeInferenceBuilder<'db> {
 
                 // Call into the context expression inference to validate that it evaluates
                 // to a valid context manager.
-                let context_expression_ty = if target.is_some() {
-                    self.infer_standalone_expression(&item.context_expr)
-                } else {
-                    self.infer_expression(&item.context_expr)
-                };
+                let context_expression_ty = self.infer_standalone_expression(&item.context_expr);
path: crates/red_knot_python_semantic/src/types/infer.rs
user: login: carljm
body: I suspect it's related to the added support for `self.x` as a with statement target, meaning we are now inferring context managers with attribute targets as standalone expressions, and that leads to double inference of the same expression(s) if we don't always go through `infer_standalone_expression`.

I think this change is correct, given that the semantic index builder always creates a standalone expression for every `with` statement.
created_at: 2025-03-06T00:52:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1262,16 +1262,64 @@ where
                 is_async,
                 ..
             }) => {
-                for item in items {
-                    self.visit_expr(&item.context_expr);
-                    if let Some(optional_vars) = item.optional_vars.as_deref() {
-                        self.add_standalone_expression(&item.context_expr);
-                        self.push_assignment(CurrentAssignment::WithItem {
-                            item,
-                            is_async: *is_async,
-                        });
+                for item @ ruff_python_ast::WithItem {
+                    range: _,
+                    context_expr,
+                    optional_vars,
+                } in items
+                {
+                    let context_manager = self.add_standalone_expression(context_expr);
path: crates/red_knot_python_semantic/src/semantic_index/builder.rs
user: login: carljm
body: This line looks redundant with line 1274? We shouldn't add `context_expr` as a standalone expression _twice_, and we shouldn't need to do it at all if there are no `optional_vars` (meaning we aren't going to create any Definition or register any attribute assignment).
created_at: 2025-03-06T00:53:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -63,6 +63,11 @@ impl<'db> Unpacker<'db> {
             });
         }
 
+        if value.is_context_manager() {
path: crates/red_knot_python_semantic/src/types/unpacker.rs
user: login: carljm
body: Totally.
created_at: 2025-03-06T00:54:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,6 +22,10 @@ pub(crate) enum AttributeAssignment<'db> {
     /// `for self.x in <iterable>`.
     Iterable { iterable: Expression<'db> },
 
+    /// An attribute assignment where the right-hand side is a context manager, for example
+    /// `with <context_manager> as self.x`.
+    ContextManager { context_manager: Expression<'db> },
path: crates/red_knot_python_semantic/src/semantic_index/attribute_assignment.rs
user: login: dhruvmanila
body: nit: "right-hand side" is a bit confusing but I understand that it's how assignment statements are usually viewed. Maybe we can reword it as "... where the (value|expression) to be assigned is a context-manager ..."
created_at: 2025-03-07T08:24:26Z
author_association: MEMBER
diff_hunk: @@ -1919,7 +1982,9 @@ enum CurrentAssignment<'a> {
     },
     WithItem {
         item: &'a ast::WithItem,
+        first: bool,
         is_async: bool,
+        unpack: Option<Unpack<'a>>,
     },
path: crates/red_knot_python_semantic/src/semantic_index/builder.rs
user: login: dhruvmanila
body: Just for visibility as stated in Discord and it doesn't need to be done in this PR but we could possibly combine `first` and `unpack` into a `Option<(UnpackPosition, Unpack<'a>)>` where the position could be either `First` or `Other`. I'm not sure what the fallout of this change would be but that would at least solve the arbitrary boolean problem when the target is a name node.
created_at: 2025-03-07T08:26:55Z
author_association: MEMBER
diff_hunk: @@ -2952,6 +2998,137 @@ pub enum TypeVarBoundOrConstraints<'db> {
     Constraints(TupleType<'db>),
 }
 
+/// Error returned if a type is not (or may not be) a context manager.
+#[derive(Debug)]
+struct ContextManagerError<'db> {
+    /// The type of the object that the analysed code attempted to use as a context manager.
+    context_manager_type: Type<'db>,
+
+    /// The precise kind of error encountered when trying to use the type as a context manager.
+    error_kind: ContextManagerErrorKind<'db>,
+}
+
+impl<'db> ContextManagerError<'db> {
+    fn enter_type(&self, db: &'db dyn Db) -> Option<Type<'db>> {
+        self.error_kind.enter_type(db)
+    }
+
+    fn fallback_enter_type(&self, db: &'db dyn Db) -> Type<'db> {
+        self.enter_type(db).unwrap_or(Type::unknown())
+    }
+
+    /// Reports the diagnostic for this error
+    fn report_diagnostic(
+        &self,
+        context: &InferContext<'db>,
+        context_manager_node: ast::AnyNodeRef,
+    ) {
+        self.error_kind
+            .report_diagnostic(context, self.context_manager_type, context_manager_node);
+    }
+}
+
+#[derive(Debug)]
+enum ContextManagerErrorKind<'db> {
+    Exit {
+        enter_ret_ty: Type<'db>,
path: crates/red_knot_python_semantic/src/types.rs
user: login: dhruvmanila
body: Can we use verbose name like `enter_return_type`? It's easier to read and can help future readers understand easily about this field.
created_at: 2025-03-07T08:47:56Z
author_association: MEMBER
diff_hunk: @@ -2952,6 +2998,137 @@ pub enum TypeVarBoundOrConstraints<'db> {
     Constraints(TupleType<'db>),
 }
 
+/// Error returned if a type is not (or may not be) a context manager.
+#[derive(Debug)]
+struct ContextManagerError<'db> {
+    /// The type of the object that the analysed code attempted to use as a context manager.
+    context_manager_type: Type<'db>,
+
+    /// The precise kind of error encountered when trying to use the type as a context manager.
+    error_kind: ContextManagerErrorKind<'db>,
+}
+
+impl<'db> ContextManagerError<'db> {
+    fn enter_type(&self, db: &'db dyn Db) -> Option<Type<'db>> {
+        self.error_kind.enter_type(db)
+    }
+
+    fn fallback_enter_type(&self, db: &'db dyn Db) -> Type<'db> {
+        self.enter_type(db).unwrap_or(Type::unknown())
+    }
+
+    /// Reports the diagnostic for this error
+    fn report_diagnostic(
+        &self,
+        context: &InferContext<'db>,
+        context_manager_node: ast::AnyNodeRef,
+    ) {
+        self.error_kind
+            .report_diagnostic(context, self.context_manager_type, context_manager_node);
+    }
+}
+
+#[derive(Debug)]
+enum ContextManagerErrorKind<'db> {
+    Exit {
+        enter_ret_ty: Type<'db>,
+        exit_error: CallDunderError<'db>,
+    },
+    Enter(CallDunderError<'db>),
+    EnterAndExit {
+        enter_error: CallDunderError<'db>,
+        exit_error: CallDunderError<'db>,
+    },
+}
+
+impl<'db> ContextManagerErrorKind<'db> {
+    fn enter_type(&self, db: &'db dyn Db) -> Option<Type<'db>> {
+        match self {
+            ContextManagerErrorKind::Exit {
+                enter_ret_ty,
+                exit_error: _,
+            } => Some(*enter_ret_ty),
+            ContextManagerErrorKind::Enter(enter_error)
+            | ContextManagerErrorKind::EnterAndExit {
+                enter_error,
+                exit_error: _,
+            } => match enter_error {
+                CallDunderError::PossiblyUnbound(call_outcome) => {
+                    Some(call_outcome.return_type(db))
+                }
+                CallDunderError::Call(call_error) => call_error.return_type(db),
+                CallDunderError::MethodNotAvailable => None,
+            },
+        }
+    }
+
+    fn format_call_dunder_error(call_dunder_error: &CallDunderError<'db>, name: &str) -> String {
+        match call_dunder_error {
+            CallDunderError::MethodNotAvailable => format!("it does not implement `{name}`"),
+            CallDunderError::PossiblyUnbound(_) => {
+                format!("the method `{name}` is possibly unbound")
+            }
+            // TODO: Use more specific error messages for the different error cases.
+            //  E.g. hint toward the union variant that doesn't correctly implement enter,
+            //  distinguish between a not callable `__enter__` attribute and a wrong signature.
+            CallDunderError::Call(_) => format!("it does not correctly implement `{name}`"),
+        }
+    }
+
+    fn format_call_dunder_errors(
+        error_a: &CallDunderError<'db>,
+        name_a: &str,
+        error_b: &CallDunderError<'db>,
+        name_b: &str,
+    ) -> String {
path: crates/red_knot_python_semantic/src/types.rs
user: login: dhruvmanila
body: nit: I think I'd prefer to make this a closure inside `report_diagnostic` as I think that's the only place where it's being utilized.
created_at: 2025-03-07T08:49:19Z
author_association: MEMBER
diff_hunk: @@ -1595,18 +1590,18 @@ impl<'db> TypeInferenceBuilder<'db> {
         } = with_statement;
         for item in items {
             let target = item.optional_vars.as_deref();
-            if let Some(ast::Expr::Name(name)) = target {
-                self.infer_definition(name);
+            // TODO infer definitions in unpacking assignment
+            if let Some(target) = target {
path: crates/red_knot_python_semantic/src/types/infer.rs
user: login: dhruvmanila
body: I think this TODO is resolved, no? The `infer_target` call will take care of inferring the targets involved in an unpacking.
created_at: 2025-03-07T08:53:10Z
author_association: MEMBER
diff_hunk: @@ -2952,6 +2998,137 @@ pub enum TypeVarBoundOrConstraints<'db> {
     Constraints(TupleType<'db>),
 }
 
+/// Error returned if a type is not (or may not be) a context manager.
+#[derive(Debug)]
+struct ContextManagerError<'db> {
+    /// The type of the object that the analysed code attempted to use as a context manager.
+    context_manager_type: Type<'db>,
+
+    /// The precise kind of error encountered when trying to use the type as a context manager.
+    error_kind: ContextManagerErrorKind<'db>,
+}
+
+impl<'db> ContextManagerError<'db> {
+    fn enter_type(&self, db: &'db dyn Db) -> Option<Type<'db>> {
+        self.error_kind.enter_type(db)
+    }
+
+    fn fallback_enter_type(&self, db: &'db dyn Db) -> Type<'db> {
+        self.enter_type(db).unwrap_or(Type::unknown())
+    }
+
+    /// Reports the diagnostic for this error
+    fn report_diagnostic(
+        &self,
+        context: &InferContext<'db>,
+        context_manager_node: ast::AnyNodeRef,
+    ) {
+        self.error_kind
+            .report_diagnostic(context, self.context_manager_type, context_manager_node);
+    }
+}
+
+#[derive(Debug)]
+enum ContextManagerErrorKind<'db> {
+    Exit {
+        enter_ret_ty: Type<'db>,
+        exit_error: CallDunderError<'db>,
+    },
+    Enter(CallDunderError<'db>),
+    EnterAndExit {
+        enter_error: CallDunderError<'db>,
+        exit_error: CallDunderError<'db>,
+    },
+}
+
+impl<'db> ContextManagerErrorKind<'db> {
+    fn enter_type(&self, db: &'db dyn Db) -> Option<Type<'db>> {
+        match self {
+            ContextManagerErrorKind::Exit {
+                enter_ret_ty,
+                exit_error: _,
+            } => Some(*enter_ret_ty),
+            ContextManagerErrorKind::Enter(enter_error)
+            | ContextManagerErrorKind::EnterAndExit {
+                enter_error,
+                exit_error: _,
+            } => match enter_error {
+                CallDunderError::PossiblyUnbound(call_outcome) => {
+                    Some(call_outcome.return_type(db))
+                }
+                CallDunderError::Call(call_error) => call_error.return_type(db),
+                CallDunderError::MethodNotAvailable => None,
+            },
+        }
+    }
+
+    fn format_call_dunder_error(call_dunder_error: &CallDunderError<'db>, name: &str) -> String {
+        match call_dunder_error {
+            CallDunderError::MethodNotAvailable => format!("it does not implement `{name}`"),
+            CallDunderError::PossiblyUnbound(_) => {
+                format!("the method `{name}` is possibly unbound")
+            }
+            // TODO: Use more specific error messages for the different error cases.
+            //  E.g. hint toward the union variant that doesn't correctly implement enter,
+            //  distinguish between a not callable `__enter__` attribute and a wrong signature.
+            CallDunderError::Call(_) => format!("it does not correctly implement `{name}`"),
+        }
+    }
+
+    fn format_call_dunder_errors(
+        error_a: &CallDunderError<'db>,
+        name_a: &str,
+        error_b: &CallDunderError<'db>,
+        name_b: &str,
+    ) -> String {
path: crates/red_knot_python_semantic/src/types.rs
user: login: ericmarkmartin
body: I think that's true right now, but we could probably use it elsewhere, right?
created_at: 2025-03-07T23:03:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -613,3 +613,98 @@ def _(arg: tuple[tuple[int, str], Iterable]):
         reveal_type(a)  # revealed: int | bytes
         reveal_type(b)  # revealed: str | bytes
 ```
+
+## With statement
+
+Unpacking in a `with` statement.
+
+### Same types
+
+```py
+class ContextManager:
+    def __enter__(self) -> tuple[int, int]:
+        return (1, 2)
+
+    def __exit__(self, exc_type, exc_value, traceback) -> None:
+        pass
+
+with ContextManager() as (a, b):
+    reveal_type(a)  # revealed: int
+    reveal_type(b)  # revealed: int
+```
+
+### Mixed types
+
+```py
+class ContextManager:
+    def __enter__(self) -> tuple[int, str]:
+        return (1, "a")
+
+    def __exit__(self, exc_type, exc_value, traceback) -> None:
+        pass
+
+with ContextManager() as (a, b):
+    reveal_type(a)  # revealed: int
+    reveal_type(b)  # revealed: str
+```
+
+### Nested
+
+```py
+class ContextManager:
+    def __enter__(self) -> tuple[int, tuple[str, bytes]]:
+        return (1, ("a", b"bytes"))
+
+    def __exit__(self, exc_type, exc_value, traceback) -> None:
+        pass
+
+with ContextManager() as (a, (b, c)):
+    reveal_type(a)  # revealed: int
+    reveal_type(b)  # revealed: str
+    reveal_type(c)  # revealed: bytes
+```
+
+### Starred expression
+
+```py
+class ContextManager:
+    def __enter__(self) -> tuple[int, int, int]:
+        return (1, 2, 3)
+
+    def __exit__(self, exc_type, exc_value, traceback) -> None:
+        pass
+
+with ContextManager() as (a, *b):
+    reveal_type(a)  # revealed: int
+    # TODO: Should be list[int] once support for assigning to starred expression is added
+    reveal_type(b)  # revealed: @Todo(starred unpacking)
+```
+
+### Unbound context manager expression
+
+```py
+# TODO: should only be one diagnostic
+# error: [unresolved-reference] "Name `nonexistant` used when not defined"
+# error: [unresolved-reference] "Name `nonexistant` used when not defined"
+# error: [unresolved-reference] "Name `nonexistant` used when not defined"
+with nonexistant as (x, y):
+    reveal_type(x)  # revealed: Unknown
+    reveal_type(y)  # revealed: Unknown
+```
+
+### Invalid unpacking
+
+```py
+class ContextManager:
+    def __enter__(self) -> tuple[int, str]:
+        return (1, "a")
+
+    def __exit__(self, *args) -> None:
+        pass
+
+# error: [invalid-assignment] "Not enough values to unpack (expected 3, got 2)"
+with ContextManager() as (a, b, c):
+    reveal_type(a)  # revealed: int
+    reveal_type(b)  # revealed: str
path: crates/red_knot_python_semantic/resources/mdtest/unpacking.md
user: login: carljm
body: This is failing now because a PR that recently landed in main branch changed our policy on how we infer types on a failed unpacking: https://github.com/astral-sh/ruff/issues/15199
```suggestion
    reveal_type(a)  # revealed: Unknown
    reveal_type(b)  # revealed: Unknown
```
created_at: 2025-03-08T02:32:20Z
author_association: CONTRIBUTOR

PR comments:
user: login: carljm
created_at: 2025-03-06T06:14:58Z
body: Ok, I looked into the diagnostics situation, and it is going to need some attention here. A bit of background will be needed to understand the issue.

We have type inference queries at several granularities: expression (but only some "standalone" expressions, so we don't create too many Salsa ingredients), definition (that is, a value assigned to some name), and scope. We use the definition granularity queries to do lazy type inference of names from scopes we aren't actually checking (e.g. imported libraries). When we are actually checking a file, we run the scope-level inference queries for every scope in the file, and it is those queries where we aggregate and emit diagnostics.

So the intent when we run scope-level inference is that as we walk the AST, anywhere we hit a node that is a definition of a name, we use `self.infer_definition` to find that Definition by key, query its types, and then merge those types and diagnostics into the scope-level `TypeInference`. What this achieves is that we never double-infer the same types (even if we first lazily query the type of a name from a scope, and then later do full scope type inference on that scope), because we always use the (Salsa-cached) `infer_definition_types` query whenever we hit a definition.

What is currently happening in this PR is that when we find an unpacked with statement in scope-level inference, we don't eagerly run `infer_definition_types` for the definitions of the unpack-target names at all. This is not good, because it means we are implicitly depending on a later load of one of those names to actually run `infer_definition_types`, go through the Unpacker, and catch any unpack errors.
 
We should add a test like this to demonstrate this:

```py
class ContextManager:
    def __enter__(self) -> tuple[int, str]:
        return (1, "a")

    def __exit__(self, exc_type, exc_value, traceback) -> None:
        pass

with ContextManager() as (a, b, c):
    pass
```

This should emit an error about trying to unpack two elements into three, but currently in this PR it does not.

The fix is that this PR needs to update `TypeInferenceBuilder::infer_with_statement`, and currently doesn't; there's even a relevant TODO statement there. In the case where the target is not a simple Name, we are currently just inferring the type of the context expression (just once! which is why you currently can't make duplicate diagnostics happen, regardless of `first`) and then we visit the optional-vars, and nothing in that visit will ever look up any of the definitions or run `self.infer_definition()` on them.

The _right_ fix for this involves fixing https://github.com/astral-sh/ty/issues/185, since this duplicate-definition thing is already broken for `for` statements, as we discovered yesterday. But that fix is not going to be simple, and I don't think we should try to fix it in this PR.

Instead, I think the goal for this PR should be to emulate the current handling of `for` statements, and be just as broken as `for` in this regard, and then the fix for https://github.com/astral-sh/ty/issues/185 will fix them both in the same way. That will mean, in this PR, updating `TypeInferenceBuilder::infer_with_statement` to look a lot more like `TypeInferenceBuilder::infer_for_statement`, and use `TypeInferenceBuilder::infer_target`.

Hopefully that all made sense! If not, feel free to ask questions, here or in Discord. Or if it doesn't make sense and you don't have time to wrap your head around it and update the PR, that's totally fine too! Just let me know and one of us can pick it up.
author_association: CONTRIBUTOR
user: login: dhruvmanila
created_at: 2025-03-08T04:00:20Z
body: @ericmarkmartin Awesome work! This is a really great first contribution. Thank you for taking this on and also surfacing an important issue related to the unpacking diagnostics.
author_association: MEMBER