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.1.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

* Make `type.__new__()` raise clear errors instead of returning `None`

* Move object dunder methods from ``FunctionModel`` to ``ObjectModel`` to make them
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
59 changes: 54 additions & 5 deletions 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 @@ -77,7 +78,7 @@ def match(
def satisfied_by(self, inferred: InferenceResult) -> bool:
"""Return True if this constraint is satisfied by the given inferred value."""
# Assume true if uninferable
if isinstance(inferred, util.UninferableBase):
if inferred is util.Uninferable:
return True

# Return the XOR of self.negate and matches(inferred, self.CONST_NONE)
Expand Down Expand Up @@ -117,14 +118,61 @@ def satisfied_by(self, inferred: InferenceResult) -> bool:
- negate=True: satisfied if boolean value is False
"""
inferred_booleaness = inferred.bool_value()
if isinstance(inferred, util.UninferableBase) or isinstance(
inferred_booleaness, util.UninferableBase
):
if inferred is util.Uninferable or inferred_booleaness is util.Uninferable:
return True

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 inferred is util.Uninferable:
return True

try:
types = helpers.class_or_tuple_to_container(self.classinfo)
matches_checked_types = helpers.object_isinstance(inferred, types)

if matches_checked_types is util.Uninferable:
return True
Comment on lines +166 to +169
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This Uninferable check is not covered by tests, it should be unreachable in runtime. I am including it as a safe guard because helpers.object_isinstance() returns bool | Uninferable, but I am also fine with removing it. Thoughts?

Copy link
Member

Choose a reason for hiding this comment

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

Can you share more details about why this is unreachable? I see that we short-circuit if inferred is uninferable, but it wasn't apparent to me why inferring again wouldn't return uninferable.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Looked into object_isinstance(), there are two main cases where it can return Uninferable:

  • When the type of the object cannot be inferred, happens when:

    • The object resolves to multiple types. This should not occur since inferred is an item within a list of inferred results.
    • Inference error is raised while doing inferred.infer(). This may occur when inferred itself is Uninferable, but as you mentioned there is already a short-circuit check above.
  • When the type can be inferred but is not an instance of nodes.ClassDef. However, it seems that the inferred type always appears to be a ClassDef, or wrapped in a ClassDef proxy.

Copy link
Member

Choose a reason for hiding this comment

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

This should not occur since inferred is an item within a list of inferred results.

That's what I'm unsure about, inferring an item may return something besides itself.

Copy link
Contributor Author

@zenlyj zenlyj Oct 19, 2025

Choose a reason for hiding this comment

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

Trying to think of a scenario where inferring a non-Uninferable inference result returns multiple results. Not sure if that's a common case but I'd be interested to know if you have any concrete examples in mind.

An alternative is to mock the method to return Uninferable:

def test_isinstance_uninferable():
    node = builder.extract_node(
        """
    x = 3

    if isinstance(x, str):
        x  #@
    """
    )

    with patch.object(helpers, "object_isinstance", return_value=Uninferable):
        inferred = node.inferred()
        assert len(inferred) == 1
        assert isinstance(inferred[0], nodes.Const)
        assert inferred[0].value == 3

Copy link
Member

Choose a reason for hiding this comment

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

I'm good with that. Sorry I don't have the time to construct a concrete example.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No worries, I will go with the mocking approach for now. If anyone has encountered this before, feel free to chime in!


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 +207,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: # pragma: no cover
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: # pragma: no cover
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
Loading
Loading