diff --git a/ChangeLog b/ChangeLog index 45e882190..e44509b37 100644 --- a/ChangeLog +++ b/ChangeLog @@ -7,6 +7,12 @@ What's New in astroid 4.0.0? ============================ Release date: TBA +* Add support for type constraints (`isinstance(x, y)`) in inference. + + Closes pylint-dev/pylint#1162 + Closes pylint-dev/pylint#4635 + Closes pylint-dev/pylint#10469 + * Support constraints from ternary expressions in inference. Closes pylint-dev/pylint#9729 diff --git a/astroid/brain/brain_builtin_inference.py b/astroid/brain/brain_builtin_inference.py index e21d36141..a2ca95514 100644 --- a/astroid/brain/brain_builtin_inference.py +++ b/astroid/brain/brain_builtin_inference.py @@ -763,7 +763,7 @@ def infer_issubclass(callnode, context: InferenceContext | None = None): # The right hand argument is the class(es) that the given # object is to be checked against. try: - class_container = _class_or_tuple_to_container( + class_container = helpers.class_or_tuple_to_container( class_or_tuple_node, context=context ) except InferenceError as exc: @@ -798,7 +798,7 @@ def infer_isinstance( # The right hand argument is the class(es) that the given # obj is to be check is an instance of try: - class_container = _class_or_tuple_to_container( + class_container = helpers.class_or_tuple_to_container( class_or_tuple_node, context=context ) except InferenceError as exc: @@ -814,30 +814,6 @@ def infer_isinstance( return nodes.Const(isinstance_bool) -def _class_or_tuple_to_container( - node: InferenceResult, context: InferenceContext | None = None -) -> list[InferenceResult]: - # Move inferences results into container - # to simplify later logic - # raises InferenceError if any of the inferences fall through - try: - node_infer = next(node.infer(context=context)) - except StopIteration as e: - raise InferenceError(node=node, context=context) from e - # arg2 MUST be a type or a TUPLE of types - # for isinstance - if isinstance(node_infer, nodes.Tuple): - try: - class_container = [ - next(node.infer(context=context)) for node in node_infer.elts - ] - except StopIteration as e: - raise InferenceError(node=node, context=context) from e - else: - class_container = [node_infer] - return class_container - - def infer_len(node, context: InferenceContext | None = None) -> nodes.Const: """Infer length calls. diff --git a/astroid/constraint.py b/astroid/constraint.py index 692d22d03..ca7f743ea 100644 --- a/astroid/constraint.py +++ b/astroid/constraint.py @@ -10,7 +10,8 @@ from collections.abc import Iterator from typing import TYPE_CHECKING -from astroid import nodes, util +from astroid import helpers, nodes, util +from astroid.exceptions import AstroidTypeError, InferenceError, MroError from astroid.typing import InferenceResult if sys.version_info >= (3, 11): @@ -125,6 +126,55 @@ def satisfied_by(self, inferred: InferenceResult) -> bool: return self.negate ^ inferred_booleaness +class TypeConstraint(Constraint): + """Represents an "isinstance(x, y)" constraint.""" + + def __init__( + self, node: nodes.NodeNG, classinfo: nodes.NodeNG, negate: bool + ) -> None: + super().__init__(node=node, negate=negate) + self.classinfo = classinfo + + @classmethod + def match( + cls, node: _NameNodes, expr: nodes.NodeNG, negate: bool = False + ) -> Self | None: + """Return a new constraint for node if expr matches the + "isinstance(x, y)" pattern. Else, return None. + """ + is_instance_call = ( + isinstance(expr, nodes.Call) + and isinstance(expr.func, nodes.Name) + and expr.func.name == "isinstance" + and not expr.keywords + and len(expr.args) == 2 + ) + if is_instance_call and _matches(expr.args[0], node): + return cls(node=node, classinfo=expr.args[1], negate=negate) + + return None + + def satisfied_by(self, inferred: InferenceResult) -> bool: + """Return True for uninferable results, or depending on negate flag: + + - negate=False: satisfied when inferred is an instance of the checked types. + - negate=True: satisfied when inferred is not an instance of the checked types. + """ + if isinstance(inferred, util.UninferableBase): + return True + + try: + types = helpers.class_or_tuple_to_container(self.classinfo) + matches_checked_types = helpers.object_isinstance(inferred, types) + + if isinstance(matches_checked_types, util.UninferableBase): + return True + + return self.negate ^ matches_checked_types + except (InferenceError, AstroidTypeError, MroError): + return True + + def get_constraints( expr: _NameNodes, frame: nodes.LocalsDictNodeNG ) -> dict[nodes.If | nodes.IfExp, set[Constraint]]: @@ -159,6 +209,7 @@ def get_constraints( ( NoneConstraint, BooleanConstraint, + TypeConstraint, ) ) """All supported constraint types.""" diff --git a/astroid/helpers.py b/astroid/helpers.py index 9c370aa32..375bb918f 100644 --- a/astroid/helpers.py +++ b/astroid/helpers.py @@ -170,6 +170,30 @@ def object_issubclass( return _object_type_is_subclass(node, class_or_seq, context=context) +def class_or_tuple_to_container( + node: InferenceResult, context: InferenceContext | None = None +) -> list[InferenceResult]: + # Move inferences results into container + # to simplify later logic + # raises InferenceError if any of the inferences fall through + try: + node_infer = next(node.infer(context=context)) + except StopIteration as e: + raise InferenceError(node=node, context=context) from e + # arg2 MUST be a type or a TUPLE of types + # for isinstance + if isinstance(node_infer, nodes.Tuple): + try: + class_container = [ + next(node.infer(context=context)) for node in node_infer.elts + ] + except StopIteration as e: + raise InferenceError(node=node, context=context) from e + else: + class_container = [node_infer] + return class_container + + def has_known_bases(klass, context: InferenceContext | None = None) -> bool: """Return whether all base classes of a class could be inferred.""" try: diff --git a/tests/test_constraint.py b/tests/test_constraint.py index 4859d4241..93fc1e26e 100644 --- a/tests/test_constraint.py +++ b/tests/test_constraint.py @@ -8,6 +8,7 @@ import pytest from astroid import builder, nodes +from astroid.bases import Instance from astroid.util import Uninferable @@ -19,6 +20,8 @@ def common_params(node: str) -> pytest.MarkDecorator: (f"{node} is not None", 3, None), (f"{node}", 3, None), (f"not {node}", None, 3), + (f"isinstance({node}, int)", 3, None), + (f"isinstance({node}, (int, str))", 3, None), ), ) @@ -773,3 +776,134 @@ def method(self, x = {fail_val}): assert isinstance(inferred[0], nodes.Const) assert inferred[0].value == fail_val assert inferred[1].value is Uninferable + + +def test_isinstance_equal_types() -> None: + """Test constraint for an object whose type is equal to the checked type.""" + node = builder.extract_node( + """ + class A: + pass + + x = A() + + if isinstance(x, A): + x #@ + """ + ) + + inferred = node.inferred() + assert len(inferred) == 1 + assert isinstance(inferred[0], Instance) + assert isinstance(inferred[0]._proxied, nodes.ClassDef) + assert inferred[0].name == "A" + + +def test_isinstance_subtype() -> None: + """Test constraint for an object whose type is a strict subtype of the checked type.""" + node = builder.extract_node( + """ + class A: + pass + + class B(A): + pass + + x = B() + + if isinstance(x, A): + x #@ + """ + ) + + inferred = node.inferred() + assert len(inferred) == 1 + assert isinstance(inferred[0], Instance) + assert isinstance(inferred[0]._proxied, nodes.ClassDef) + assert inferred[0].name == "B" + + +def test_isinstance_unrelated_types(): + """Test constraint for an object whose type is not related to the checked type.""" + node = builder.extract_node( + """ + class A: + pass + + class B: + pass + + x = A() + + if isinstance(x, B): + x #@ + """ + ) + + inferred = node.inferred() + assert len(inferred) == 1 + assert inferred[0] is Uninferable + + +def test_isinstance_supertype(): + """Test constraint for an object whose type is a strict supertype of the checked type.""" + node = builder.extract_node( + """ + class A: + pass + + class B(A): + pass + + x = A() + + if isinstance(x, B): + x #@ + """ + ) + + inferred = node.inferred() + assert len(inferred) == 1 + assert inferred[0] is Uninferable + + +def test_isinstance_keyword_arguments(): + """Test that constraint does not apply when `isinstance` is called + with keyword arguments. + """ + n1, n2 = builder.extract_node( + """ + x = 3 + + if isinstance(object=x, classinfo=str): + x #@ + + if isinstance(x, str, object=x, classinfo=str): + x #@ + """ + ) + + for node in (n1, n2): + inferred = node.inferred() + assert len(inferred) == 1 + assert isinstance(inferred[0], nodes.Const) + assert inferred[0].value == 3 + + +def test_isinstance_extra_argument(): + """Test that constraint does not apply when `isinstance` is called + with more than two positional arguments. + """ + node = builder.extract_node( + """ + x = 3 + + if isinstance(x, str, bool): + x #@ + """ + ) + + inferred = node.inferred() + assert len(inferred) == 1 + assert isinstance(inferred[0], nodes.Const) + assert inferred[0].value == 3