Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ What's New in astroid 4.2.0?
============================
Release date: TBA

* Add support for ``and`` and ``or`` boolean constraints in inference.


What's New in astroid 4.1.1?
Expand Down
96 changes: 96 additions & 0 deletions astroid/constraint.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,100 @@ def satisfied_by(self, inferred: InferenceResult) -> bool:
return True


class AndConstraint(Constraint):
"""Represents a "x and y" constraint."""

def __init__(
self,
node: nodes.NodeNG,
negate: bool,
children: list[Constraint],
) -> None:
super().__init__(node=node, negate=negate)
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 the
"x and y" pattern.

Return None if expr is not an "and" expression, or if any
operand does not match a constraint pattern.
"""
if isinstance(expr, nodes.BoolOp) and expr.op == "and":
children: list[Constraint] = []
for value in expr.values:
matches = list(_match_constraint(node, value))
if not matches:
return None
children.extend(matches)
return cls(node=node, negate=negate, children=children)

return None

def satisfied_by(self, inferred: InferenceResult) -> bool:
"""Return True for uninferable results, or depending on negate flag:

- negate=False: satisfied when all children constraints are satisfied.
- negate=True: satisfied when at least one child constraint is not satisfied.
"""
if isinstance(inferred, util.UninferableBase):
return True

return self.negate ^ all(
constraint.satisfied_by(inferred) for constraint in self.children
)


class OrConstraint(Constraint):
"""Represents a "x or y" constraint."""

def __init__(
self,
node: nodes.NodeNG,
negate: bool,
children: list[Constraint],
) -> None:
super().__init__(node=node, negate=negate)
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 the
"x or y" pattern.

Return None if expr is not an "or" expression, or if any
operand does not match a constraint pattern.
"""
if isinstance(expr, nodes.BoolOp) and expr.op == "or":
children: list[Constraint] = []
for value in expr.values:
matches = list(_match_constraint(node, value))
if not matches:
return None
children.extend(matches)
return cls(node=node, negate=negate, children=children)

return None

def satisfied_by(self, inferred: InferenceResult) -> bool:
"""Return True for uninferable results, or depending on negate flag:

- negate=False: satisfied when at least one child constraint is satisfied.
- negate=True: satisfied when all children constraints are not satisfied.
"""
if isinstance(inferred, util.UninferableBase):
return True

return self.negate ^ any(
constraint.satisfied_by(inferred) for constraint in self.children
)


def get_constraints(
expr: _NameNodes, frame: nodes.LocalsDictNodeNG
) -> dict[nodes.If | nodes.IfExp, set[Constraint]]:
Expand Down Expand Up @@ -266,6 +360,8 @@ def get_constraints(
BooleanConstraint,
TypeConstraint,
EqualityConstraint,
AndConstraint,
OrConstraint,
)
)
"""All supported constraint types."""
Expand Down
97 changes: 97 additions & 0 deletions tests/test_constraint.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,43 @@ def common_params(node: str) -> pytest.MarkDecorator:
(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"{node} is not None and (isinstance({node}, int) and {node} == 3)",
3,
None,
), # Nested AND
(
f"{node} is not None and {node} and isinstance({node}, int) and {node} == 3",
3,
0,
), # AND with multiple constraints
(f"isinstance({node}, str) or {node} == 3", 3, None),
(
f"{node} is None or (isinstance({node}, str) or {node} == 3)",
None,
5,
), # Nested OR
(
f"{node} is None or not {node} or isinstance({node}, str) or {node} == 3",
0,
5,
), # OR with multiple constraints
(
f"{node} is not None and (isinstance({node}, bool) or {node} == 3)",
True,
None,
), # AND with nested OR
(
f"{node} is None or (isinstance({node}, bool) and {node} == 3)",
None,
5,
), # OR with nested AND
(
f"{node} == 3 or isinstance({node}, int) and {node} == 5",
3,
None,
), # AND precedence over OR
),
)

Expand Down Expand Up @@ -1141,3 +1178,63 @@ def test_equality_fractions():
assert isinstance(inferred[0], Instance), msg
assert isinstance(inferred[0]._proxied, nodes.ClassDef), msg
assert inferred[0]._proxied.name == "Fraction", msg


def test_and_expression_with_non_constraint():
"""Test that constraint is satisfied when an "and" expression contains a non-constraint operand."""
node = builder.extract_node("""
x, y = 3, None

if not x and y:
x #@
""")

inferred = node.inferred()
assert len(inferred) == 1
assert isinstance(inferred[0], nodes.Const)
assert inferred[0].value == 3


def test_and_expression_with_nested_non_constraint():
"""Test that constraint is satisfied when a nested "and" expression contains a non-constraint operand."""
node = builder.extract_node("""
x, y = 3, None

if x is not None and (not x and y):
x #@
""")

inferred = node.inferred()
assert len(inferred) == 1
assert isinstance(inferred[0], nodes.Const)
assert inferred[0].value == 3


def test_or_expression_with_non_constraint():
"""Test that constraint is satisfied when an "or" expression contains a non-constraint operand."""
node = builder.extract_node("""
x, y = 3, None

if not x or y:
x #@
""")

inferred = node.inferred()
assert len(inferred) == 1
assert isinstance(inferred[0], nodes.Const)
assert inferred[0].value == 3


def test_or_expression_with_nested_non_constraint():
"""Test that constraint is satisfied when a nested "or" expression contains a non-constraint operand."""
node = builder.extract_node("""
x, y = 3, None

if x is None or (not x or y):
x #@
""")

inferred = node.inferred()
assert len(inferred) == 1
assert isinstance(inferred[0], nodes.Const)
assert inferred[0].value == 3
Loading