Merge branch 'maintenance/4.0.x' into post-4.0.3
diff --git a/.coveragerc b/.coveragerc index 55838fc..ca605f7 100644 --- a/.coveragerc +++ b/.coveragerc
@@ -3,6 +3,7 @@ [report] omit = + */astroid/__main__.py */tests/* */tmp*/* exclude_lines =
diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 4d644f4..d627224 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml
@@ -25,7 +25,7 @@ ) ) steps: - - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 id: app-token with: app-id: ${{ vars.BACKPORT_APP_ID }}
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 98edfb7..4f17d84 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml
@@ -26,11 +26,11 @@ steps: - &checkout name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@v6.0.1 - &setup-python-default name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@v6.1.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -43,7 +43,7 @@ - &cache-python name: Restore Python virtual environment id: cache-venv - uses: actions/cache@v4.3.0 + uses: actions/cache@v5.0.1 with: path: venv key: >- @@ -63,7 +63,7 @@ hashFiles('.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT - name: Restore pre-commit environment id: cache-precommit - uses: actions/cache@v4.3.0 + uses: actions/cache@v5.0.1 with: path: ${{ env.PRE_COMMIT_CACHE }} key: >- @@ -92,7 +92,7 @@ - *checkout - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@v6.1.0 with: python-version: ${{ matrix.python-version }} allow-prereleases: true @@ -122,7 +122,7 @@ . venv/bin/activate pytest --cov - name: Upload coverage artifact - uses: &actions-upload-artifact actions/upload-artifact@v4.6.2 + uses: &actions-upload-artifact actions/upload-artifact@v6.0.0 with: name: coverage-linux-${{ matrix.python-version }} path: .coverage @@ -146,7 +146,7 @@ - &setup-python-matrix name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@v6.1.0 with: python-version: ${{ matrix.python-version }} allow-prereleases: true @@ -226,7 +226,7 @@ - name: Install dependencies run: pip install -U -r requirements_minimal.txt - name: Download all coverage artifacts - uses: actions/download-artifact@v5.0.0 + uses: actions/download-artifact@v7.0.0 - name: Combine Linux coverage results run: | coverage combine coverage-linux*/.coverage
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 9b30ac2..f11f399 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml
@@ -46,11 +46,11 @@ steps: - name: Checkout repository - uses: actions/checkout@v5.0.0 + uses: actions/checkout@v6.0.1 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -61,7 +61,7 @@ # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@v4 # âšī¸ Command-line programs to run using the OS shell. # đ https://git.io/JvXDl @@ -75,4 +75,4 @@ # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c6cbdb..af5c9a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml
@@ -18,10 +18,10 @@ if: github.event_name == 'release' steps: - name: Check out code from Github - uses: actions/checkout@v5.0.0 + uses: actions/checkout@v6.0.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@v6.1.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -34,7 +34,7 @@ run: | python -m build - name: Upload release assets - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@v6.0.0 with: name: release-assets path: dist/ @@ -50,7 +50,7 @@ id-token: write steps: - name: Download release assets - uses: actions/download-artifact@v5.0.0 + uses: actions/download-artifact@v7.0.0 with: name: release-assets path: dist/ @@ -67,13 +67,13 @@ id-token: write steps: - name: Download release assets - uses: actions/download-artifact@v5.0.0 + uses: actions/download-artifact@v7.0.0 with: name: release-assets path: dist/ - name: Sign the dists with Sigstore and upload assets to Github release if: github.event_name == 'release' - uses: sigstore/gh-action-sigstore-python@v3.0.1 + uses: sigstore/gh-action-sigstore-python@v3.2.0 with: inputs: | ./dist/*.tar.gz
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 70095b1..6d2ef84 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml
@@ -10,7 +10,7 @@ - id: end-of-file-fixer exclude: tests/testdata - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.13.2" + rev: "v0.14.10" hooks: - id: ruff-check args: ["--fix"] @@ -22,7 +22,7 @@ exclude: tests/testdata|setup.py types: [python] - repo: https://github.com/asottile/pyupgrade - rev: v3.20.0 + rev: v3.21.2 hooks: - id: pyupgrade exclude: tests/testdata @@ -32,8 +32,8 @@ hooks: - id: black-disable-checker exclude: tests/test_nodes_lineno.py - - repo: https://github.com/psf/black - rev: 25.9.0 + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 25.12.0 hooks: - id: black args: [--safe, --quiet] @@ -68,7 +68,7 @@ ] stages: [manual] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.18.2 + rev: v1.19.1 hooks: - id: mypy language: python @@ -76,11 +76,11 @@ require_serial: true additional_dependencies: ["types-typed-ast"] - repo: https://github.com/rbubley/mirrors-prettier - rev: v3.6.2 + rev: v3.7.4 hooks: - id: prettier args: [--prose-wrap=always, --print-width=88] - repo: https://github.com/tox-dev/pyproject-fmt - rev: "v2.6.0" + rev: "v2.11.1" hooks: - id: pyproject-fmt
diff --git a/ChangeLog b/ChangeLog index df6f72e..0e4312e 100644 --- a/ChangeLog +++ b/ChangeLog
@@ -7,6 +7,35 @@ ============================ 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 + available on all object types, not just functions. + + Closes #2742 + Closes #2741 + Closes pylint-dev/pylint#6094 + +* ``lineno`` and ``end_lineno`` are now available on ``Arguments``. + +* Add helper to iterate over all annotations nodes of function arguments, + ``Arguments.get_annotations()``. + + Refs #2860 + +* Skip direct parent when determining the ``Decorator`` frame. + + Refs pylint-dev/pylint#8425 + +* Add simple command line interface for astroid to output generated AST. + Use with ``python -m astroid``. + What's New in astroid 4.0.4? @@ -133,7 +162,7 @@ Closes #2672 -* Add basic support for ``ast.TemplateStr`` and ``ast.Interpolation``added in Python 3.14. +* Add basic support for ``ast.TemplateStr`` and ``ast.Interpolation`` added in Python 3.14. Refs #2789
diff --git a/astroid/__main__.py b/astroid/__main__.py new file mode 100644 index 0000000..90a40d6 --- /dev/null +++ b/astroid/__main__.py
@@ -0,0 +1,54 @@ +# Licensed under the LGPL: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.en.html +# For details: https://github.com/pylint-dev/astroid/blob/main/LICENSE +# Copyright (c) https://github.com/pylint-dev/astroid/blob/main/CONTRIBUTORS.txt + +"""Command line interface for astroid.""" + +from __future__ import annotations + +import sys +from argparse import ArgumentParser, Namespace +from collections.abc import Callable, Sequence +from pathlib import Path +from typing import cast + +import astroid + + +class Arguments(Namespace): + func: Callable[[Arguments], int] + + +class ASTParserArguments(Arguments): + file: str + + +def parse_ast(args: ASTParserArguments) -> int: + if not ((file := Path(args.file)).is_file() and file.suffix in {".py", ".pyi"}): + print(f"error: '{file}' does not exist or isn't a Python file") + return 1 + + tree = astroid.parse(file.read_text(encoding="utf8")) + print(tree.repr_tree()) + return 0 + + +def main(argv: Sequence[str] | None = None) -> int: + argv = argv or sys.argv[1:] + parser = ArgumentParser(description="Command line interface for astroid") + subparsers = parser.add_subparsers() + + ast_parser = subparsers.add_parser("ast", help="Print astroid AST") + ast_parser.set_defaults(func=parse_ast) + ast_parser.add_argument("file", metavar="FILE", help="File to parse") + + args = cast(Arguments, parser.parse_args(argv)) + if "func" not in args: + parser.print_help() + return 2 + + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main())
diff --git a/astroid/__pkginfo__.py b/astroid/__pkginfo__.py index feea93f..dfe71c3 100644 --- a/astroid/__pkginfo__.py +++ b/astroid/__pkginfo__.py
@@ -2,5 +2,5 @@ # For details: https://github.com/pylint-dev/astroid/blob/main/LICENSE # Copyright (c) https://github.com/pylint-dev/astroid/blob/main/CONTRIBUTORS.txt -__version__ = "4.0.3" +__version__ = "4.1.0-dev0" version = __version__
diff --git a/astroid/bases.py b/astroid/bases.py index a029da6..7fb3da2 100644 --- a/astroid/bases.py +++ b/astroid/bases.py
@@ -250,7 +250,9 @@ values = self._proxied.instance_attr(name, context) except AttributeInferenceError as exc: if self.special_attributes and name in self.special_attributes: - return [self.special_attributes.lookup(name)] + special_attr = self.special_attributes.lookup(name) + if not isinstance(special_attr, nodes.Unknown): + return [special_attr] if lookupclass: # Class attributes not available through the instance @@ -571,10 +573,14 @@ raise InferenceError(context=context) from e if not isinstance(mcs, nodes.ClassDef): # Not a valid first argument. - return None + raise InferenceError( + "type.__new__() requires a class for metaclass", context=context + ) if not mcs.is_subtype_of("builtins.type"): # Not a valid metaclass. - return None + raise InferenceError( + "type.__new__() metaclass must be a subclass of type", context=context + ) # Verify the name try: @@ -583,10 +589,14 @@ raise InferenceError(context=context) from e if not isinstance(name, nodes.Const): # Not a valid name, needs to be a const. - return None + raise InferenceError( + "type.__new__() requires a constant for name", context=context + ) if not isinstance(name.value, str): # Needs to be a string. - return None + raise InferenceError( + "type.__new__() requires a string for name", context=context + ) # Verify the bases try: @@ -595,14 +605,18 @@ raise InferenceError(context=context) from e if not isinstance(bases, nodes.Tuple): # Needs to be a tuple. - return None + raise InferenceError( + "type.__new__() requires a tuple for bases", context=context + ) try: inferred_bases = [next(elt.infer(context=context)) for elt in bases.elts] except StopIteration as e: raise InferenceError(context=context) from e if any(not isinstance(base, nodes.ClassDef) for base in inferred_bases): # All the bases needs to be Classes - return None + raise InferenceError( + "type.__new__() requires classes for bases", context=context + ) # Verify the attributes. try: @@ -611,7 +625,9 @@ raise InferenceError(context=context) from e if not isinstance(attrs, nodes.Dict): # Needs to be a dictionary. - return None + raise InferenceError( + "type.__new__() requires a dict for attrs", context=context + ) cls_locals: dict[str, list[InferenceResult]] = collections.defaultdict(list) for key, value in attrs.items: try: @@ -664,9 +680,13 @@ and self.bound.name == "type" and self.name == "__new__" and isinstance(caller, nodes.Call) - and len(caller.args) == 4 ): # Check if we have a ``type.__new__(mcs, name, bases, attrs)`` call. + if len(caller.args) != 4: + raise InferenceError( + f"type.__new__() requires 4 arguments, got {len(caller.args)}", + context=context, + ) new_cls = self._infer_type_new_call(caller, context) if new_cls: return iter((new_cls,))
diff --git a/astroid/brain/brain_builtin_inference.py b/astroid/brain/brain_builtin_inference.py index e21d361..a2ca955 100644 --- a/astroid/brain/brain_builtin_inference.py +++ b/astroid/brain/brain_builtin_inference.py
@@ -763,7 +763,7 @@ # 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 @@ # 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 @@ 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 692d22d..693de59 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): @@ -77,7 +78,7 @@ 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) @@ -117,14 +118,61 @@ - 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 + + 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 +207,7 @@ ( NoneConstraint, BooleanConstraint, + TypeConstraint, ) ) """All supported constraint types."""
diff --git a/astroid/helpers.py b/astroid/helpers.py index 9c370aa..deef3d9 100644 --- a/astroid/helpers.py +++ b/astroid/helpers.py
@@ -170,6 +170,30 @@ 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:
diff --git a/astroid/interpreter/objectmodel.py b/astroid/interpreter/objectmodel.py index eac9e43..3745107 100644 --- a/astroid/interpreter/objectmodel.py +++ b/astroid/interpreter/objectmodel.py
@@ -163,6 +163,33 @@ return bases.BoundMethod(proxy=node, bound=_get_bound_node(self)) + # Base object attributes that return Unknown as fallback placeholders. + @property + def attr___ne__(self): + return node_classes.Unknown(parent=self._instance) + + attr___class__ = attr___ne__ + attr___delattr__ = attr___ne__ + attr___dir__ = attr___ne__ + attr___doc__ = attr___ne__ + attr___eq__ = attr___ne__ + attr___format__ = attr___ne__ + attr___ge__ = attr___ne__ + attr___getattribute__ = attr___ne__ + attr___getstate__ = attr___ne__ + attr___gt__ = attr___ne__ + attr___hash__ = attr___ne__ + attr___init_subclass__ = attr___ne__ + attr___le__ = attr___ne__ + attr___lt__ = attr___ne__ + attr___reduce__ = attr___ne__ + attr___reduce_ex__ = attr___ne__ + attr___repr__ = attr___ne__ + attr___setattr__ = attr___ne__ + attr___sizeof__ = attr___ne__ + attr___str__ = attr___ne__ + attr___subclasshook__ = attr___ne__ + class ModuleModel(ObjectModel): def _builtins(self): @@ -459,30 +486,15 @@ return DescriptorBoundMethod(proxy=self._instance, bound=self._instance) - # These are here just for completion. + # Function-specific attributes. @property - def attr___ne__(self): + def attr___call__(self): return node_classes.Unknown(parent=self._instance) - attr___subclasshook__ = attr___ne__ - attr___str__ = attr___ne__ - attr___sizeof__ = attr___ne__ - attr___setattr___ = attr___ne__ - attr___repr__ = attr___ne__ - attr___reduce__ = attr___ne__ - attr___reduce_ex__ = attr___ne__ - attr___lt__ = attr___ne__ - attr___eq__ = attr___ne__ - attr___gt__ = attr___ne__ - attr___format__ = attr___ne__ - attr___delattr___ = attr___ne__ - attr___getattribute__ = attr___ne__ - attr___hash__ = attr___ne__ - attr___dir__ = attr___ne__ - attr___call__ = attr___ne__ - attr___class__ = attr___ne__ - attr___closure__ = attr___ne__ - attr___code__ = attr___ne__ + attr___builtins__ = attr___call__ + attr___closure__ = attr___call__ + attr___code__ = attr___call__ + attr___type_params__ = attr___call__ class ClassModel(ObjectModel):
diff --git a/astroid/nodes/node_classes.py b/astroid/nodes/node_classes.py index b9077de..7f0887b 100644 --- a/astroid/nodes/node_classes.py +++ b/astroid/nodes/node_classes.py
@@ -55,6 +55,7 @@ if TYPE_CHECKING: from astroid import nodes from astroid.nodes import LocalsDictNodeNG + from astroid.nodes.node_ng import FrameType def _is_const(value) -> bool: @@ -1020,6 +1021,23 @@ if elt is not None: yield elt + def get_annotations(self) -> Iterator[nodes.NodeNG]: + """Iterate over all annotations nodes.""" + for elt in self.posonlyargs_annotations: + if elt is not None: + yield elt + for elt in self.annotations: + if elt is not None: + yield elt + if self.varargannotation is not None: + yield self.varargannotation + + for elt in self.kwonlyargs_annotations: + if elt is not None: + yield elt + if self.kwargannotation is not None: + yield self.kwargannotation + @decorators.raise_if_nothing_inferred def _infer( self, context: InferenceContext | None = None, **kwargs: Any @@ -2222,13 +2240,22 @@ :returns: The first parent scope node. """ - # skip the function node to go directly to the upper level scope + # skip the function or class node to go directly to the upper level scope if not self.parent: raise ParentMissingError(target=self) if not self.parent.parent: raise ParentMissingError(target=self.parent) return self.parent.parent.scope() + def frame(self) -> FrameType: + """The first parent node defining a new frame.""" + # skip the function or class node to go directly to the upper level frame + if not self.parent: + raise ParentMissingError(target=self) + if not self.parent.parent: + raise ParentMissingError(target=self.parent) + return self.parent.parent.frame() + def get_children(self): yield from self.nodes @@ -4916,7 +4943,7 @@ See astroid/protocols.py for actual implementation. """ - def frame(self) -> nodes.FunctionDef | nodes.Module | nodes.ClassDef | nodes.Lambda: + def frame(self) -> FrameType: """The first parent frame node. A frame node is a :class:`Module`, :class:`FunctionDef`, @@ -4972,9 +4999,10 @@ class Unknown(_base_nodes.AssignTypeNode): """This node represents a node in a constructed AST where - introspection is not possible. At the moment, it's only used in - the args attribute of FunctionDef nodes where function signature - introspection failed. + introspection is not possible. + + Used in the args attribute of FunctionDef nodes where function signature + introspection failed, and as a placeholder in ObjectModel. """ name = "Unknown"
diff --git a/astroid/nodes/node_ng.py b/astroid/nodes/node_ng.py index 1af39c2..af40c68 100644 --- a/astroid/nodes/node_ng.py +++ b/astroid/nodes/node_ng.py
@@ -42,6 +42,8 @@ if TYPE_CHECKING: from astroid.nodes import _base_nodes + FrameType = nodes.FunctionDef | nodes.Module | nodes.ClassDef | nodes.Lambda + # Types for 'NodeNG.nodes_of_class()' _NodesT = TypeVar("_NodesT", bound="NodeNG") @@ -284,7 +286,7 @@ raise StatementMissing(target=self) return self.parent.statement() - def frame(self) -> nodes.FunctionDef | nodes.Module | nodes.ClassDef | nodes.Lambda: + def frame(self) -> FrameType: """The first parent frame node. A frame node is a :class:`Module`, :class:`FunctionDef`,
diff --git a/astroid/nodes/scoped_nodes/scoped_nodes.py b/astroid/nodes/scoped_nodes/scoped_nodes.py index f9b06bf..744970f 100644 --- a/astroid/nodes/scoped_nodes/scoped_nodes.py +++ b/astroid/nodes/scoped_nodes/scoped_nodes.py
@@ -1624,6 +1624,16 @@ yield node_classes.Const(None) return + # Builtin dunder methods have empty bodies, return Uninferable. + if ( + len(self.body) == 0 + and self.name.startswith("__") + and self.name.endswith("__") + and self.root().qname() == "builtins" + ): + yield util.Uninferable + return + raise InferenceError("The function does not have any return statements") for returnnode in itertools.chain((first_return,), returns): @@ -2352,8 +2362,10 @@ values += classnode.locals.get(name, []) if name in self.special_attributes and class_context and not values: - result = [self.special_attributes.lookup(name)] - return result + special_attr = self.special_attributes.lookup(name) + if not isinstance(special_attr, node_classes.Unknown): + result = [special_attr] + return result if class_context: values += self._metaclass_lookup_attribute(name, context)
diff --git a/astroid/raw_building.py b/astroid/raw_building.py index d1bbbd5..e7c5562 100644 --- a/astroid/raw_building.py +++ b/astroid/raw_building.py
@@ -78,7 +78,11 @@ """create a Const node and register it in the locals of the given node with the specified name """ - if name not in node.special_attributes: + # Special case: __hash__ = None overrides ObjectModel for unhashable types. + # See https://docs.python.org/3/reference/datamodel.html#object.__hash__ + if name == "__hash__" and value is None: + _attach_local_node(node, nodes.const_factory(value), name) + elif name not in node.special_attributes: _attach_local_node(node, nodes.const_factory(value), name) @@ -507,7 +511,11 @@ elif inspect.isdatadescriptor(member): child = object_build_datadescriptor(node, member) elif isinstance(member, tuple(node_classes.CONST_CLS)): - if alias in node.special_attributes: + # Special case: __hash__ = None overrides ObjectModel for unhashable types. + # See https://docs.python.org/3/reference/datamodel.html#object.__hash__ + if alias in node.special_attributes and not ( + alias == "__hash__" and member is None + ): continue child = nodes.const_factory(member) elif inspect.isroutine(member):
diff --git a/astroid/rebuilder.py b/astroid/rebuilder.py index 97f3a39..f7d38a9 100644 --- a/astroid/rebuilder.py +++ b/astroid/rebuilder.py
@@ -9,6 +9,7 @@ from __future__ import annotations import ast +import itertools import sys import token from collections.abc import Callable, Collection, Generator @@ -589,6 +590,16 @@ type_comment_kwonlyargs=type_comment_kwonlyargs, type_comment_posonlyargs=type_comment_posonlyargs, ) + if start_end_lineno_pairs := [ + (arg.lineno, arg.end_lineno) + for arg in itertools.chain( + node.args, node.posonlyargs, node.kwonlyargs, [node.vararg, node.kwarg] + ) + if arg + ]: + newnode.lineno = min(startend[0] for startend in start_end_lineno_pairs) + newnode.end_lineno = max(startend[1] for startend in start_end_lineno_pairs) + # save argument names in locals: assert newnode.parent if vararg:
diff --git a/doc/requirements.txt b/doc/requirements.txt index cf9e808..a759a74 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt
@@ -1,3 +1,3 @@ -e . sphinx~=8.1 -furo==2025.9.25 +furo==2025.12.19
diff --git a/requirements_minimal.txt b/requirements_minimal.txt index 9fde495..c1457cf 100644 --- a/requirements_minimal.txt +++ b/requirements_minimal.txt
@@ -3,7 +3,7 @@ tbump~=6.11 # Tools used to run tests -coverage~=7.10 +coverage~=7.13 pytest pytest-cov~=7.0 mypy; platform_python_implementation!="PyPy"
diff --git a/tbump.toml b/tbump.toml index 29eba19..42dcf88 100644 --- a/tbump.toml +++ b/tbump.toml
@@ -1,7 +1,7 @@ github_url = "https://github.com/pylint-dev/astroid" [version] -current = "4.0.3" +current = "4.1.0-dev0" regex = ''' ^(?P<major>0|[1-9]\d*) \.
diff --git a/tests/test_constraint.py b/tests/test_constraint.py index 4859d42..f69e0e4 100644 --- a/tests/test_constraint.py +++ b/tests/test_constraint.py
@@ -5,9 +5,12 @@ """Tests for inference involving constraints.""" from __future__ import annotations +from unittest.mock import patch + import pytest from astroid import builder, nodes +from astroid.bases import Instance from astroid.util import Uninferable @@ -19,6 +22,8 @@ (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 +778,295 @@ 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_multiple_inheritance(): + """Test constraint for an object that inherits from more than one parent class.""" + n1, n2, n3 = builder.extract_node( + """ + class A: + pass + + class B: + pass + + class C(A, B): + pass + + x = C() + + if isinstance(x, C): + x #@ + + if isinstance(x, A): + x #@ + + if isinstance(x, B): + x #@ + """ + ) + + for node in (n1, n2, n3): + inferred = node.inferred() + assert len(inferred) == 1 + assert isinstance(inferred[0], Instance) + assert isinstance(inferred[0]._proxied, nodes.ClassDef) + assert inferred[0].name == "C" + + +def test_isinstance_diamond_inheritance(): + """Test constraint for an object that inherits from parent classes + in diamond inheritance. + """ + n1, n2, n3, n4 = builder.extract_node( + """ + class A(): + pass + + class B(A): + pass + + class C(A): + pass + + class D(B, C): + pass + + x = D() + + if isinstance(x, D): + x #@ + + if isinstance(x, B): + x #@ + + if isinstance(x, C): + x #@ + + if isinstance(x, A): + x #@ + """ + ) + + for node in (n1, n2, n3, n4): + inferred = node.inferred() + assert len(inferred) == 1 + assert isinstance(inferred[0], Instance) + assert isinstance(inferred[0]._proxied, nodes.ClassDef) + assert inferred[0].name == "D" + + +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 + + +def test_isinstance_classinfo_inference_error(): + """Test that constraint is satisfied when `isinstance` is called with + classinfo that raises an inference error. + """ + node = builder.extract_node( + """ + x = 3 + + if isinstance(x, undefined_type): + x #@ + """ + ) + + inferred = node.inferred() + assert len(inferred) == 1 + assert isinstance(inferred[0], nodes.Const) + assert inferred[0].value == 3 + + +def test_isinstance_uninferable_classinfo(): + """Test that constraint is satisfied when `isinstance` is called with + uninferable classinfo. + """ + node = builder.extract_node( + """ + def f(classinfo): + x = 3 + + if isinstance(x, classinfo): + x #@ + """ + ) + + inferred = node.inferred() + assert len(inferred) == 1 + assert isinstance(inferred[0], nodes.Const) + assert inferred[0].value == 3 + + +def test_isinstance_mro_error(): + """Test that constraint is satisfied when computing the object's + method resolution order raises an MRO error. + """ + node = builder.extract_node( + """ + class A(): + pass + + class B(A, 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_uninferable(): + """Test that constraint is satisfied when `isinstance` inference returns Uninferable.""" + node = builder.extract_node( + """ + x = 3 + + if isinstance(x, str): + x #@ + """ + ) + + with patch( + "astroid.constraint.helpers.object_isinstance", return_value=Uninferable + ): + inferred = node.inferred() + assert len(inferred) == 1 + assert isinstance(inferred[0], nodes.Const) + assert inferred[0].value == 3
diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 4df145b..65d9780 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py
@@ -10,7 +10,7 @@ from astroid import builder, helpers, manager, nodes, raw_building, util from astroid.builder import AstroidBuilder from astroid.const import IS_PYPY -from astroid.exceptions import _NonDeducibleTypeHierarchy +from astroid.exceptions import InferenceError, _NonDeducibleTypeHierarchy from astroid.nodes.node_classes import UNATTACHED_UNKNOWN @@ -275,3 +275,70 @@ "Import safe_infer from astroid.util; this shim in astroid.helpers will be removed." in records[0].message.args[0] ) + + +def test_class_to_container() -> None: + node = builder.extract_node("""isinstance(3, int)""") + + container = helpers.class_or_tuple_to_container(node.args[1]) + + assert len(container) == 1 + assert isinstance(container[0], nodes.ClassDef) + assert container[0].name == "int" + + +def test_tuple_to_container() -> None: + node = builder.extract_node("""isinstance(3, (int, str))""") + + container = helpers.class_or_tuple_to_container(node.args[1]) + + assert len(container) == 2 + + assert isinstance(container[0], nodes.ClassDef) + assert container[0].name == "int" + + assert isinstance(container[1], nodes.ClassDef) + assert container[1].name == "str" + + +def test_class_to_container_uninferable() -> None: + node = builder.extract_node( + """ + def f(x): + isinstance(3, x) #@ + """ + ) + + container = helpers.class_or_tuple_to_container(node.args[1]) + + assert len(container) == 1 + assert container[0] is util.Uninferable + + +def test_tuple_to_container_uninferable() -> None: + node = builder.extract_node( + """ + def f(x, y): + isinstance(3, (x, y)) #@ + """ + ) + + container = helpers.class_or_tuple_to_container(node.args[1]) + + assert len(container) == 2 + assert container[0] is util.Uninferable + assert container[1] is util.Uninferable + + +def test_class_to_container_inference_error() -> None: + node = builder.extract_node("""isinstance(3, undefined_type)""") + + with pytest.raises(InferenceError): + helpers.class_or_tuple_to_container(node.args[1]) + + +def test_tuple_to_container_inference_error() -> None: + node = builder.extract_node("""isinstance(3, (int, undefined_type))""") + + with pytest.raises(InferenceError): + helpers.class_or_tuple_to_container(node.args[1])
diff --git a/tests/test_inference.py b/tests/test_inference.py index 6a19220..e0d47a7 100644 --- a/tests/test_inference.py +++ b/tests/test_inference.py
@@ -1503,7 +1503,7 @@ def test_python25_no_relative_import(self) -> None: ast = resources.build_file("data/package/absimport.py") - self.assertTrue(ast.absolute_import_activated(), True) + self.assertTrue(ast.absolute_import_activated()) inferred = next( test_utils.get_name_node(ast, "import_package_subpackage_module").infer() ) @@ -1512,7 +1512,7 @@ def test_nonregr_absolute_import(self) -> None: ast = resources.build_file("data/absimp/string.py", "data.absimp.string") - self.assertTrue(ast.absolute_import_activated(), True) + self.assertTrue(ast.absolute_import_activated()) inferred = next(test_utils.get_name_node(ast, "string").infer()) self.assertIsInstance(inferred, nodes.Module) self.assertEqual(inferred.name, "string") @@ -5618,8 +5618,9 @@ ) inferred = next(node.infer()) lenmeth = next(inferred.igetattr("__len__")) - with pytest.raises(InferenceError): - next(lenmeth.infer_call_result(None, None)) + # Builtin dunder methods now return Uninferable instead of raising InferenceError + result = next(lenmeth.infer_call_result(None, None)) + assert result is util.Uninferable def test_unpack_dicts_in_assignment() -> None:
diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 8d2f4d5..939c581 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py
@@ -2314,6 +2314,19 @@ assert node.args.default_value("flavor").value == "good" +def test_arguments_annotations(): + node = extract_node( + "def fruit(eat: str, /, peel: bool, *args: int, trim: float, **kwargs: bytes): ..." + ) + assert isinstance(node.args, nodes.Arguments) + annotation_names = [ + ann.name for ann in node.args.get_annotations() if isinstance(ann, nodes.Name) + ] + assert all( + name in annotation_names for name in ("str", "bool", "int", "float", "bytes") + ) + + def test_deprecated_nodes_import_from_toplevel(): # pylint: disable=import-outside-toplevel,no-name-in-module with pytest.raises(
diff --git a/tests/test_nodes_lineno.py b/tests/test_nodes_lineno.py index f8c6f91..25fb339 100644 --- a/tests/test_nodes_lineno.py +++ b/tests/test_nodes_lineno.py
@@ -589,6 +589,8 @@ assert (f1.body[0].lineno, f1.body[0].col_offset) == (6, 4) assert (f1.body[0].end_lineno, f1.body[0].end_col_offset) == (6, 8) + assert (f1.args.lineno, f1.args.end_lineno) == (2, 4) + # pos only arguments # TODO fix column offset: arg -> arg (AssignName) assert isinstance(f1.args.posonlyargs[0], nodes.AssignName)
diff --git a/tests/test_object_model.py b/tests/test_object_model.py index f3015db..f36a0d5 100644 --- a/tests/test_object_model.py +++ b/tests/test_object_model.py
@@ -265,10 +265,13 @@ xml.__setattr__ #@ xml.__reduce_ex__ #@ xml.__lt__ #@ + xml.__le__ #@ xml.__eq__ #@ + xml.__ne__ #@ + xml.__ge__ #@ xml.__gt__ #@ xml.__format__ #@ - xml.__delattr___ #@ + xml.__delattr__ #@ xml.__getattribute__ #@ xml.__hash__ #@ xml.__dir__ #@ @@ -324,9 +327,13 @@ new_ = next(ast_nodes[10].infer()) assert isinstance(new_, bases.BoundMethod) - # The following nodes are just here for theoretical completeness, - # and they either return Uninferable or raise InferenceError. - for ast_node in ast_nodes[11:28]: + # Inherited attributes return Uninferable. + for ast_node in ast_nodes[11:29]: + inferred = next(ast_node.infer()) + self.assertIs(inferred, astroid.Uninferable) + + # Attributes that don't exist on modules raise InferenceError. + for ast_node in ast_nodes[29:31]: with pytest.raises(InferenceError): next(ast_node.infer()) @@ -449,16 +456,23 @@ func.__reduce_ex__ #@ func.__lt__ #@ + func.__le__ #@ func.__eq__ #@ + func.__ne__ #@ + func.__ge__ #@ func.__gt__ #@ func.__format__ #@ - func.__delattr___ #@ + func.__delattr__ #@ func.__getattribute__ #@ func.__hash__ #@ func.__dir__ #@ func.__class__ #@ func.__setattr__ #@ + func.__builtins__ #@ + func.__getstate__ #@ + func.__init_subclass__ #@ + func.__type_params__ #@ ''', module_name="fake_module", ) @@ -511,16 +525,11 @@ new_ = next(ast_nodes[10].infer()) assert isinstance(new_, bases.BoundMethod) - # The following nodes are just here for theoretical completeness, - # and they either return Uninferable or raise InferenceError. - for ast_node in ast_nodes[11:26]: + # Remaining attributes return Uninferable. + for ast_node in ast_nodes[11:34]: inferred = next(ast_node.infer()) assert inferred is util.Uninferable - for ast_node in ast_nodes[26:27]: - with pytest.raises(InferenceError): - inferred = next(ast_node.infer()) - def test_empty_return_annotation(self) -> None: ast_node = builder.extract_node( """ @@ -897,3 +906,112 @@ assert next(apple.infer()) is astroid.Uninferable assert isinstance(pear, nodes.Attribute) assert next(pear.infer()) is astroid.Uninferable + + +def test_object_dunder_methods_can_be_overridden() -> None: + """Test that ObjectModel dunders don't block class overrides.""" + # Test instance method override + eq_result = builder.extract_node( + """ + class MyClass: + def __eq__(self, other): + return "custom equality" + + MyClass().__eq__(None) #@ + """ + ) + inferred = next(eq_result.infer()) + assert isinstance(inferred, nodes.Const) + assert inferred.value == "custom equality" + + # Test that __eq__ on instance returns a bound method + eq_method = builder.extract_node( + """ + class MyClass: + def __eq__(self, other): + return True + + MyClass().__eq__ #@ + """ + ) + inferred = next(eq_method.infer()) + assert isinstance(inferred, astroid.BoundMethod) + + # Test other commonly overridden dunders + for dunder, return_val in ( + ("__ne__", "not equal"), + ("__lt__", "less than"), + ("__le__", "less or equal"), + ("__gt__", "greater than"), + ("__ge__", "greater or equal"), + ("__str__", "string repr"), + ("__repr__", "repr"), + ("__hash__", 42), + ): + node = builder.extract_node( + f""" + class MyClass: + def {dunder}(self, *args): + return {return_val!r} + + MyClass().{dunder}() #@ + """ + ) + inferred = next(node.infer()) + assert isinstance(inferred, nodes.Const), f"{dunder} failed to infer correctly" + assert inferred.value == return_val, f"{dunder} returned wrong value" + + +def test_unoverridden_object_dunders_return_uninferable() -> None: + """Test that un-overridden object dunders return Uninferable when called.""" + for dunder in ( + "__eq__", + "__hash__", + "__lt__", + "__le__", + "__gt__", + "__ge__", + "__ne__", + ): + node = builder.extract_node( + f""" + class MyClass: + pass + + MyClass().{dunder}(None) if "{dunder}" != "__hash__" else MyClass().{dunder}() #@ + """ + ) + result = next(node.infer()) + assert result is util.Uninferable + + +def test_all_object_dunders_accessible() -> None: + """Test that object dunders are accessible on classes and instances.""" + # Use actual dunders from object in the current Python version + object_dunders = [attr for attr in dir(object) if attr.startswith("__")] + + cls, instance = builder.extract_node( + """ + class MyClass: + pass + + MyClass #@ + MyClass() #@ + """ + ) + cls = next(cls.infer()) + instance = next(instance.infer()) + + for dunder in object_dunders: + assert cls.getattr(dunder) + assert instance.getattr(dunder) + + +def test_hash_none_for_unhashable_builtins() -> None: + """Test that unhashable builtin types have __hash__ = None.""" + for type_name in ("list", "dict", "set"): + node = builder.extract_node(f"{type_name} #@") + cls = next(node.infer()) + hash_attr = cls.getattr("__hash__")[0] + assert isinstance(hash_attr, nodes.Const) + assert hash_attr.value is None
diff --git a/tests/test_scoped_nodes.py b/tests/test_scoped_nodes.py index f3ac0a6..85aebab 100644 --- a/tests/test_scoped_nodes.py +++ b/tests/test_scoped_nodes.py
@@ -2938,6 +2938,39 @@ assert module.frame() == module @staticmethod + def test_frame_node_for_decorators(): + code = builder.extract_node( + """ + def deco(var): + def inner(arg): + ... + return inner + + @deco( + x := 1 #@ + ) + def func(): #@ + ... + + @deco( + y := 2 #@ + ) + class A: #@ + ... + """ + ) + name_expr_node1, func_node, name_expr_node2, class_node = code + module = func_node.root() + assert name_expr_node1.scope() == module + assert name_expr_node1.frame() == module + assert name_expr_node2.scope() == module + assert name_expr_node2.frame() == module + assert module.locals.get("x") == [name_expr_node1.target] + assert module.locals.get("y") == [name_expr_node2.target] + assert "x" not in func_node.locals + assert "y" not in class_node.locals + + @staticmethod def test_non_frame_node(): """Test if the frame of non frame nodes is set correctly.""" module = builder.parse(