From efd8f1c5dcd8351ec173818777ad591a80d653b9 Mon Sep 17 00:00:00 2001 From: Zen Lee Date: Sat, 4 Oct 2025 22:27:19 +0800 Subject: [PATCH 1/4] Implement type constraint --- astroid/brain/brain_builtin_inference.py | 28 +------------ astroid/constraint.py | 53 +++++++++++++++++++++++- astroid/helpers.py | 24 +++++++++++ 3 files changed, 78 insertions(+), 27 deletions(-) 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: From 6f9e005007cd2017b289cee0bf705d491cd46573 Mon Sep 17 00:00:00 2001 From: Zen Lee Date: Sat, 4 Oct 2025 22:33:37 +0800 Subject: [PATCH 2/4] Add tests --- tests/test_constraint.py | 134 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/tests/test_constraint.py b/tests/test_constraint.py index 4859d4241..f742cefbc 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( + f""" + 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( + f""" + 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( + f""" + 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( + f""" + 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( + f""" + 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( + f""" + 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 From 3626725ace9f192e75093905a22650706da7bc51 Mon Sep 17 00:00:00 2001 From: Zen Lee Date: Sat, 4 Oct 2025 22:33:53 +0800 Subject: [PATCH 3/4] Add changelog --- ChangeLog | 6 ++++++ 1 file changed, 6 insertions(+) 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 From e5217a2b8e7f42ae79c2b71231ca7e8ffdc4af13 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 4 Oct 2025 15:35:28 +0000 Subject: [PATCH 4/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_constraint.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_constraint.py b/tests/test_constraint.py index f742cefbc..93fc1e26e 100644 --- a/tests/test_constraint.py +++ b/tests/test_constraint.py @@ -781,7 +781,7 @@ def method(self, x = {fail_val}): def test_isinstance_equal_types() -> None: """Test constraint for an object whose type is equal to the checked type.""" node = builder.extract_node( - f""" + """ class A: pass @@ -802,7 +802,7 @@ class 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( - f""" + """ class A: pass @@ -826,7 +826,7 @@ class B(A): def test_isinstance_unrelated_types(): """Test constraint for an object whose type is not related to the checked type.""" node = builder.extract_node( - f""" + """ class A: pass @@ -848,7 +848,7 @@ class B: def test_isinstance_supertype(): """Test constraint for an object whose type is a strict supertype of the checked type.""" node = builder.extract_node( - f""" + """ class A: pass @@ -872,7 +872,7 @@ def test_isinstance_keyword_arguments(): with keyword arguments. """ n1, n2 = builder.extract_node( - f""" + """ x = 3 if isinstance(object=x, classinfo=str): @@ -895,7 +895,7 @@ def test_isinstance_extra_argument(): with more than two positional arguments. """ node = builder.extract_node( - f""" + """ x = 3 if isinstance(x, str, bool):