diff --git a/astroid/exceptions.py b/astroid/exceptions.py index 81d973031b..b8838023e4 100644 --- a/astroid/exceptions.py +++ b/astroid/exceptions.py @@ -272,6 +272,24 @@ def __init__(self, target: "nodes.NodeNG") -> None: super().__init__(message=f"Parent not found on {target!r}.") +class StatementMissing(ParentMissingError): + """Raised when a call to node.statement() does not return a node. This is because + a node in the chain does not have a parent attribute and therefore does not + return a node for statement(). + + Standard attributes: + target: The node for which the parent lookup failed. + """ + + def __init__(self, target: "nodes.NodeNG") -> None: + # pylint: disable-next=bad-super-call + # https://github.com/PyCQA/pylint/issues/2903 + # https://github.com/PyCQA/astroid/pull/1217#discussion_r744149027 + super(ParentMissingError, self).__init__( + message=f"Statement not found on {target!r}" + ) + + # Backwards-compatibility aliases OperationError = util.BadOperationMessage UnaryOperationError = util.BadUnaryOperationMessage diff --git a/astroid/nodes/node_ng.py b/astroid/nodes/node_ng.py index e6d0d50b1b..6fb242cd61 100644 --- a/astroid/nodes/node_ng.py +++ b/astroid/nodes/node_ng.py @@ -1,5 +1,7 @@ import pprint +import sys import typing +import warnings from functools import singledispatch as _singledispatch from typing import ( TYPE_CHECKING, @@ -10,6 +12,7 @@ Type, TypeVar, Union, + cast, overload, ) @@ -18,6 +21,7 @@ AstroidError, InferenceError, ParentMissingError, + StatementMissing, UseInferenceDefault, ) from astroid.manager import AstroidManager @@ -27,6 +31,17 @@ if TYPE_CHECKING: from astroid import nodes +if sys.version_info >= (3, 6, 2): + from typing import NoReturn +else: + from typing_extensions import NoReturn + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + + # Types for 'NodeNG.nodes_of_class()' T_Nodes = TypeVar("T_Nodes", bound="NodeNG") T_Nodes2 = TypeVar("T_Nodes2", bound="NodeNG") @@ -248,15 +263,41 @@ def parent_of(self, node): return True return False - def statement(self): + @overload + def statement( + self, *, future: Literal[None] = ... + ) -> Union["nodes.Statement", "nodes.Module"]: + ... + + @overload + def statement(self, *, future: Literal[True]) -> "nodes.Statement": + ... + + def statement( + self, *, future: Literal[None, True] = None + ) -> Union["nodes.Statement", "nodes.Module", NoReturn]: """The first parent node, including self, marked as statement node. - :returns: The first parent statement. - :rtype: NodeNG + TODO: Deprecate the future parameter and only raise StatementMissing and return + nodes.Statement + + :raises AttributeError: If self has no parent attribute + :raises StatementMissing: If self has no parent attribute and future is True """ if self.is_statement: - return self - return self.parent.statement() + return cast("nodes.Statement", self) + if not self.parent: + if future: + raise StatementMissing(target=self) + warnings.warn( + "In astroid 3.0.0 NodeNG.statement() will return either a nodes.Statement " + "or raise a StatementMissing exception. AttributeError will no longer be raised. " + "This behaviour can already be triggered " + "by passing 'future=True' to a statement() call.", + DeprecationWarning, + ) + raise AttributeError(f"{self} object has no attribute 'parent'") + return self.parent.statement(future=future) def frame( self, diff --git a/astroid/nodes/scoped_nodes.py b/astroid/nodes/scoped_nodes.py index fc4cd24005..df153b8875 100644 --- a/astroid/nodes/scoped_nodes.py +++ b/astroid/nodes/scoped_nodes.py @@ -44,8 +44,10 @@ import io import itertools import os +import sys import typing -from typing import List, Optional, TypeVar +import warnings +from typing import List, Optional, TypeVar, Union, overload from astroid import bases from astroid import decorators as decorators_mod @@ -65,6 +67,7 @@ InconsistentMroError, InferenceError, MroError, + StatementMissing, TooManyLevelsError, ) from astroid.interpreter.dunder_lookup import lookup @@ -72,6 +75,18 @@ from astroid.manager import AstroidManager from astroid.nodes import Arguments, Const, node_classes +if sys.version_info >= (3, 6, 2): + from typing import NoReturn +else: + from typing_extensions import NoReturn + + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + + ITER_METHODS = ("__iter__", "__getitem__") EXCEPTION_BASE_CLASSES = frozenset({"Exception", "BaseException"}) objects = util.lazy_import("objects") @@ -637,12 +652,34 @@ def fully_defined(self): """ return self.file is not None and self.file.endswith(".py") - def statement(self): + @overload + def statement(self, *, future: Literal[None] = ...) -> "Module": + ... + + @overload + def statement(self, *, future: Literal[True]) -> NoReturn: + ... + + def statement( + self, *, future: Literal[None, True] = None + ) -> Union[NoReturn, "Module"]: """The first parent node, including self, marked as statement node. - :returns: The first parent statement. - :rtype: NodeNG + When called on a :class:`Module` with the future parameter this raises an error. + + TODO: Deprecate the future parameter and only raise StatementMissing + + :raises StatementMissing: If no self has no parent attribute and future is True """ + if future: + raise StatementMissing(target=self) + warnings.warn( + "In astroid 3.0.0 NodeNG.statement() will return either a nodes.Statement " + "or raise a StatementMissing exception. nodes.Module will no longer be " + "considered a statement. This behaviour can already be triggered " + "by passing 'future=True' to a statement() call.", + DeprecationWarning, + ) return self def previous_sibling(self): diff --git a/tests/unittest_builder.py b/tests/unittest_builder.py index 300e4e92d0..11019e1ba0 100644 --- a/tests/unittest_builder.py +++ b/tests/unittest_builder.py @@ -38,6 +38,7 @@ AstroidSyntaxError, AttributeInferenceError, InferenceError, + StatementMissing, ) from astroid.nodes.scoped_nodes import Module @@ -614,7 +615,11 @@ def test_module_base_props(self) -> None: self.assertEqual(module.package, 0) self.assertFalse(module.is_statement) self.assertEqual(module.statement(), module) - self.assertEqual(module.statement(), module) + with pytest.warns(DeprecationWarning) as records: + module.statement() + assert len(records) == 1 + with self.assertRaises(StatementMissing): + module.statement(future=True) def test_module_locals(self) -> None: """test the 'locals' dictionary of an astroid module""" diff --git a/tests/unittest_nodes.py b/tests/unittest_nodes.py index 73b4cecbd3..10607fe1a8 100644 --- a/tests/unittest_nodes.py +++ b/tests/unittest_nodes.py @@ -55,6 +55,7 @@ AstroidBuildingError, AstroidSyntaxError, AttributeInferenceError, + StatementMissing, ) from astroid.nodes.node_classes import ( AssignAttr, @@ -626,6 +627,12 @@ def _test(self, value: Any) -> None: self.assertIs(node.value, value) self.assertTrue(node._proxied.parent) self.assertEqual(node._proxied.root().name, value.__class__.__module__) + with self.assertRaises(AttributeError): + with pytest.warns(DeprecationWarning) as records: + node.statement() + assert len(records) == 1 + with self.assertRaises(StatementMissing): + node.statement(future=True) def test_none(self) -> None: self._test(None) diff --git a/tests/unittest_scoped_nodes.py b/tests/unittest_scoped_nodes.py index 2045e1732c..db673629ff 100644 --- a/tests/unittest_scoped_nodes.py +++ b/tests/unittest_scoped_nodes.py @@ -323,6 +323,7 @@ def test_default_value(self) -> None: def test_navigation(self) -> None: function = self.module["global_access"] self.assertEqual(function.statement(), function) + self.assertEqual(function.statement(future=True), function) l_sibling = function.previous_sibling() # check taking parent if child is not a stmt self.assertIsInstance(l_sibling, nodes.Assign) @@ -821,6 +822,7 @@ def test_instance_special_attributes(self) -> None: def test_navigation(self) -> None: klass = self.module["YO"] self.assertEqual(klass.statement(), klass) + self.assertEqual(klass.statement(future=True), klass) l_sibling = klass.previous_sibling() self.assertTrue(isinstance(l_sibling, nodes.FunctionDef), l_sibling) self.assertEqual(l_sibling.name, "global_access")