Skip to content

Commit 9108781

Browse files
authored
Support isinstance constraints in inference (#2846)
1 parent a2958a6 commit 9108781

File tree

6 files changed

+451
-32
lines changed

6 files changed

+451
-32
lines changed

ChangeLog

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ What's New in astroid 4.1.0?
77
============================
88
Release date: TBA
99

10+
* Add support for type constraints (`isinstance(x, y)`) in inference.
11+
12+
Closes pylint-dev/pylint#1162
13+
Closes pylint-dev/pylint#4635
14+
Closes pylint-dev/pylint#10469
15+
1016
* Make `type.__new__()` raise clear errors instead of returning `None`
1117

1218
* Move object dunder methods from ``FunctionModel`` to ``ObjectModel`` to make them

astroid/brain/brain_builtin_inference.py

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -763,7 +763,7 @@ def infer_issubclass(callnode, context: InferenceContext | None = None):
763763
# The right hand argument is the class(es) that the given
764764
# object is to be checked against.
765765
try:
766-
class_container = _class_or_tuple_to_container(
766+
class_container = helpers.class_or_tuple_to_container(
767767
class_or_tuple_node, context=context
768768
)
769769
except InferenceError as exc:
@@ -798,7 +798,7 @@ def infer_isinstance(
798798
# The right hand argument is the class(es) that the given
799799
# obj is to be check is an instance of
800800
try:
801-
class_container = _class_or_tuple_to_container(
801+
class_container = helpers.class_or_tuple_to_container(
802802
class_or_tuple_node, context=context
803803
)
804804
except InferenceError as exc:
@@ -814,30 +814,6 @@ def infer_isinstance(
814814
return nodes.Const(isinstance_bool)
815815

816816

817-
def _class_or_tuple_to_container(
818-
node: InferenceResult, context: InferenceContext | None = None
819-
) -> list[InferenceResult]:
820-
# Move inferences results into container
821-
# to simplify later logic
822-
# raises InferenceError if any of the inferences fall through
823-
try:
824-
node_infer = next(node.infer(context=context))
825-
except StopIteration as e:
826-
raise InferenceError(node=node, context=context) from e
827-
# arg2 MUST be a type or a TUPLE of types
828-
# for isinstance
829-
if isinstance(node_infer, nodes.Tuple):
830-
try:
831-
class_container = [
832-
next(node.infer(context=context)) for node in node_infer.elts
833-
]
834-
except StopIteration as e:
835-
raise InferenceError(node=node, context=context) from e
836-
else:
837-
class_container = [node_infer]
838-
return class_container
839-
840-
841817
def infer_len(node, context: InferenceContext | None = None) -> nodes.Const:
842818
"""Infer length calls.
843819

astroid/constraint.py

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
from collections.abc import Iterator
1111
from typing import TYPE_CHECKING
1212

13-
from astroid import nodes, util
13+
from astroid import helpers, nodes, util
14+
from astroid.exceptions import AstroidTypeError, InferenceError, MroError
1415
from astroid.typing import InferenceResult
1516

1617
if sys.version_info >= (3, 11):
@@ -77,7 +78,7 @@ def match(
7778
def satisfied_by(self, inferred: InferenceResult) -> bool:
7879
"""Return True if this constraint is satisfied by the given inferred value."""
7980
# Assume true if uninferable
80-
if isinstance(inferred, util.UninferableBase):
81+
if inferred is util.Uninferable:
8182
return True
8283

8384
# Return the XOR of self.negate and matches(inferred, self.CONST_NONE)
@@ -117,14 +118,61 @@ def satisfied_by(self, inferred: InferenceResult) -> bool:
117118
- negate=True: satisfied if boolean value is False
118119
"""
119120
inferred_booleaness = inferred.bool_value()
120-
if isinstance(inferred, util.UninferableBase) or isinstance(
121-
inferred_booleaness, util.UninferableBase
122-
):
121+
if inferred is util.Uninferable or inferred_booleaness is util.Uninferable:
123122
return True
124123

125124
return self.negate ^ inferred_booleaness
126125

127126

127+
class TypeConstraint(Constraint):
128+
"""Represents an "isinstance(x, y)" constraint."""
129+
130+
def __init__(
131+
self, node: nodes.NodeNG, classinfo: nodes.NodeNG, negate: bool
132+
) -> None:
133+
super().__init__(node=node, negate=negate)
134+
self.classinfo = classinfo
135+
136+
@classmethod
137+
def match(
138+
cls, node: _NameNodes, expr: nodes.NodeNG, negate: bool = False
139+
) -> Self | None:
140+
"""Return a new constraint for node if expr matches the
141+
"isinstance(x, y)" pattern. Else, return None.
142+
"""
143+
is_instance_call = (
144+
isinstance(expr, nodes.Call)
145+
and isinstance(expr.func, nodes.Name)
146+
and expr.func.name == "isinstance"
147+
and not expr.keywords
148+
and len(expr.args) == 2
149+
)
150+
if is_instance_call and _matches(expr.args[0], node):
151+
return cls(node=node, classinfo=expr.args[1], negate=negate)
152+
153+
return None
154+
155+
def satisfied_by(self, inferred: InferenceResult) -> bool:
156+
"""Return True for uninferable results, or depending on negate flag:
157+
158+
- negate=False: satisfied when inferred is an instance of the checked types.
159+
- negate=True: satisfied when inferred is not an instance of the checked types.
160+
"""
161+
if inferred is util.Uninferable:
162+
return True
163+
164+
try:
165+
types = helpers.class_or_tuple_to_container(self.classinfo)
166+
matches_checked_types = helpers.object_isinstance(inferred, types)
167+
168+
if matches_checked_types is util.Uninferable:
169+
return True
170+
171+
return self.negate ^ matches_checked_types
172+
except (InferenceError, AstroidTypeError, MroError):
173+
return True
174+
175+
128176
def get_constraints(
129177
expr: _NameNodes, frame: nodes.LocalsDictNodeNG
130178
) -> dict[nodes.If | nodes.IfExp, set[Constraint]]:
@@ -159,6 +207,7 @@ def get_constraints(
159207
(
160208
NoneConstraint,
161209
BooleanConstraint,
210+
TypeConstraint,
162211
)
163212
)
164213
"""All supported constraint types."""

astroid/helpers.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,30 @@ def object_issubclass(
170170
return _object_type_is_subclass(node, class_or_seq, context=context)
171171

172172

173+
def class_or_tuple_to_container(
174+
node: InferenceResult, context: InferenceContext | None = None
175+
) -> list[InferenceResult]:
176+
# Move inferences results into container
177+
# to simplify later logic
178+
# raises InferenceError if any of the inferences fall through
179+
try:
180+
node_infer = next(node.infer(context=context))
181+
except StopIteration as e: # pragma: no cover
182+
raise InferenceError(node=node, context=context) from e
183+
# arg2 MUST be a type or a TUPLE of types
184+
# for isinstance
185+
if isinstance(node_infer, nodes.Tuple):
186+
try:
187+
class_container = [
188+
next(node.infer(context=context)) for node in node_infer.elts
189+
]
190+
except StopIteration as e: # pragma: no cover
191+
raise InferenceError(node=node, context=context) from e
192+
else:
193+
class_container = [node_infer]
194+
return class_container
195+
196+
173197
def has_known_bases(klass, context: InferenceContext | None = None) -> bool:
174198
"""Return whether all base classes of a class could be inferred."""
175199
try:

0 commit comments

Comments
 (0)