Skip to content
Open
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
6 changes: 6 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 2 additions & 26 deletions astroid/brain/brain_builtin_inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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.

Expand Down
53 changes: 52 additions & 1 deletion astroid/constraint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker, but would be nice to get a microbenchmark to prove whether we should be doing isinstance or is. I'm assuming is Uninferable is faster.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

>>> from astroid.util import UninferableBase
>>> from timeit import timeit
>>> uninf = UninferableBase()
>>> x = UninferableBase()
>>> timeit("x is uninf", globals=globals())
0.04412866701022722
>>> timeit("isinstance(x, UninferableBase)", globals=globals())
0.051591374998679385

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]]:
Expand Down Expand Up @@ -159,6 +209,7 @@ def get_constraints(
(
NoneConstraint,
BooleanConstraint,
TypeConstraint,
)
)
"""All supported constraint types."""
Expand Down
24 changes: 24 additions & 0 deletions astroid/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
134 changes: 134 additions & 0 deletions tests/test_constraint.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import pytest

from astroid import builder, nodes
from astroid.bases import Instance
from astroid.util import Uninferable


Expand All @@ -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),
),
)

Expand Down Expand Up @@ -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():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple ideas for test cases at your option:

  • class A(B, C): pass; isinstance(a(), C)
  • diamond inheritance

"""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