Implement `And` and `Or` constraints (#2977)


Co-authored-by: Pierre Sassoulas <pierre.sassoulas@gmail.com>
diff --git a/astroid/constraint.py b/astroid/constraint.py
index 8f7e6b2..af6c59f 100644
--- a/astroid/constraint.py
+++ b/astroid/constraint.py
@@ -247,6 +247,61 @@
         return True
 
 
+class _CompoundConstraint(Constraint):
+    """Represents an "x and y" or "x or y" constraint."""
+
+    def __init__(
+        self,
+        node: nodes.NodeNG,
+        op: str,
+        children: list[Constraint],
+        negate: bool,
+    ) -> None:
+        super().__init__(node=node, negate=negate)
+        self.op = op
+        self.children = children
+
+    @classmethod
+    def match(
+        cls, node: _NameNodes, expr: nodes.NodeNG, negate: bool = False
+    ) -> Self | None:
+        """Return a new constraint for node if expr matches an "x and y" or
+        "x or y" pattern.
+
+        Return None if expr is not a supported boolean expression, or if any
+        operand does not match a constraint pattern.
+        """
+        if not (isinstance(expr, nodes.BoolOp) and expr.op in {"and", "or"}):
+            return None
+
+        children: list[Constraint] = []
+        for value in expr.values:
+            matches = list(_match_constraint(node, value, negate))
+            if not matches:
+                return None
+            children.extend(matches)
+
+        return cls(node=node, op=expr.op, children=children, negate=negate)
+
+    def satisfied_by(
+        self, inferred: InferenceResult, context: InferenceContext
+    ) -> bool:
+        """Return True for uninferable results, or depending on op and negate:
+
+        - negate=False: all children must be satisfied for "and", or any for "or".
+        - negate=True: any child must be satisfied for "and", or all for "or".
+        """
+        if inferred is util.Uninferable:
+            return True
+
+        results = (
+            constraint.satisfied_by(inferred, context) for constraint in self.children
+        )
+
+        strict = (self.op == "and") ^ self.negate
+        return all(results) if strict else any(results)
+
+
 def get_constraints(
     expr: _NameNodes, frame: nodes.LocalsDictNodeNG
 ) -> dict[nodes.NodeNG, set[Constraint]]:
@@ -310,15 +365,15 @@
             constraints_mapping[if_expr] = constraints
 
 
-ALL_CONSTRAINT_CLASSES = frozenset(
-    (
-        NoneConstraint,
-        BooleanConstraint,
-        TypeConstraint,
-        EqualityConstraint,
-    )
-)
-"""All supported constraint types."""
+_CONSTRAINTS_BY_NODE_TYPE: dict[type[nodes.NodeNG], tuple[type[Constraint], ...]] = {
+    nodes.Attribute: (BooleanConstraint,),
+    nodes.BoolOp: (_CompoundConstraint,),
+    nodes.Call: (TypeConstraint,),
+    nodes.Compare: (NoneConstraint, EqualityConstraint),
+    nodes.Name: (BooleanConstraint,),
+    nodes.UnaryOp: (BooleanConstraint,),
+}
+"""Constraint types that can match each expression node type."""
 
 
 def _matches(node1: nodes.NodeNG | bases.Proxy, node2: nodes.NodeNG) -> bool:
@@ -337,7 +392,7 @@
     node: _NameNodes, expr: nodes.NodeNG, invert: bool = False
 ) -> Iterator[Constraint]:
     """Yields all constraint patterns for node that match."""
-    for constraint_cls in ALL_CONSTRAINT_CLASSES:
+    for constraint_cls in _CONSTRAINTS_BY_NODE_TYPE.get(type(expr), ()):
         constraint = constraint_cls.match(node, expr, invert)
         if constraint:
             yield constraint
diff --git a/doc/whatsnew/fragments/2977.feature b/doc/whatsnew/fragments/2977.feature
new file mode 100644
index 0000000..c21f46c
--- /dev/null
+++ b/doc/whatsnew/fragments/2977.feature
@@ -0,0 +1,8 @@
+Inference now narrows values through conditions joined by ``and`` and ``or``.
+A variable tested by several constraints at once, as in
+``if isinstance(apple, int) and apple != 3``, keeps only the values satisfying
+every operand, and the ``or`` and negated forms are handled too. Inference stays
+conservative when an operand cannot be inferred or does not constrain the
+variable.
+
+Closes #2977
diff --git a/tests/test_constraint.py b/tests/test_constraint.py
index b77b3b5..22cd1d7 100644
--- a/tests/test_constraint.py
+++ b/tests/test_constraint.py
@@ -33,6 +33,8 @@
             (f"{node} != 3", None, 3),
             (f"3 == {node}", 3, None),
             (f"3 != {node}", None, 3),
+            (f"isinstance({node}, int) and {node} == 3", 3, 5),
+            (f"isinstance({node}, str) or {node} == 3", 3, None),
         ),
     )
 
@@ -1316,3 +1318,148 @@
     assert len(inferred) == 1
     assert isinstance(inferred[0], nodes.Const)
     assert inferred[0].value == 1
+
+
+@pytest.mark.parametrize(
+    ("condition", "satisfy_val", "fail_val"),
+    (
+        pytest.param(
+            "x is not None and (isinstance(x, int) and x == 3)",
+            3,
+            5,
+            id="nested-and",
+        ),
+        pytest.param(
+            "x is not None and x and isinstance(x, int) and x == 3",
+            3,
+            0,
+            id="and-with-multiple-operands",
+        ),
+        pytest.param(
+            "x is None or (isinstance(x, str) or x == 3)",
+            3,
+            5,
+            id="nested-or",
+        ),
+        pytest.param(
+            "x is None or not x or isinstance(x, str) or x == 3",
+            0,
+            5,
+            id="or-with-multiple-operands",
+        ),
+        pytest.param(
+            "x is not None and (isinstance(x, bool) or x == 3)",
+            True,
+            5,
+            id="and-with-nested-or",
+        ),
+        pytest.param(
+            "x is None or (isinstance(x, int) and x == 3)",
+            3,
+            5,
+            id="or-with-nested-and",
+        ),
+        pytest.param(
+            "x == 3 or isinstance(x, int) and x == 5",
+            3,
+            None,
+            id="and-precedence-over-or",
+        ),
+    ),
+)
+def test_compound_constraint(
+    condition: str, satisfy_val: int | None, fail_val: int | None
+) -> None:
+    """Test compound constraints in both the body and else branch of an if."""
+    n1, n2, n3, n4 = builder.extract_node(f"""
+    def f1(x = {fail_val}):
+        if {condition}:
+            x  #@
+        else:
+            x  #@
+
+    def f2(x = {satisfy_val}):
+        if {condition}:
+            x  #@
+        else:
+            x  #@
+    """)
+
+    for node in (n1, n4):
+        msg = node_info(node)
+        inferred = node.inferred()
+        assert len(inferred) == 1, msg
+        assert inferred[0] is Uninferable, msg
+
+    for node, expected in ((n2, fail_val), (n3, satisfy_val)):
+        msg = node_info(node)
+        inferred = node.inferred()
+        assert len(inferred) == 2, msg
+        assert isinstance(inferred[0], nodes.Const), msg
+        assert inferred[0].value == expected, msg
+        assert inferred[1] is Uninferable, msg
+
+
+@pytest.mark.parametrize(
+    "condition",
+    (
+        pytest.param("isinstance(x, classinfo) and x == 3", id="and"),
+        pytest.param("isinstance(x, classinfo) or x == 5", id="or"),
+    ),
+)
+def test_compound_constraint_with_uninferable_child(condition: str) -> None:
+    """Test that inference remains conservative when a child constraint is unknown."""
+    n1, n2 = builder.extract_node(f"""
+    def f(classinfo, x = 3):
+        if {condition}:
+            x  #@
+        else:
+            x  #@
+    """)
+
+    for node in (n1, n2):
+        msg = node_info(node)
+        inferred = node.inferred()
+        assert len(inferred) == 2, msg
+        assert isinstance(inferred[0], nodes.Const), msg
+        assert inferred[0].value == 3, msg
+        assert inferred[1].value is Uninferable, msg
+
+
+@pytest.mark.parametrize(
+    ("condition", "value"),
+    (
+        pytest.param("not x and y", 3, id="and"),
+        pytest.param(
+            "x is not None and (not x and y)",
+            3,
+            id="nested-and-inner",
+        ),
+        pytest.param(
+            "x is not None and (not x and y)",
+            None,
+            id="nested-and-outer",
+        ),
+        pytest.param("not x or y", 3, id="or"),
+        pytest.param(
+            "x is None or (not x or y)",
+            3,
+            id="nested-or",
+        ),
+    ),
+)
+def test_expression_with_non_constraint_is_ignored(
+    condition: str, value: int | None
+) -> None:
+    """Test that an expression is ignored when an operand does not constrain the target variable."""
+    node = builder.extract_node(f"""
+    x, y = {value}, None
+
+    if {condition}:
+        x  #@
+    """)
+
+    inferred = node.inferred()
+    assert len(inferred) == 1
+    assert isinstance(inferred[0], nodes.Const)
+    assert inferred[0].value == value