diff --git a/ChangeLog b/ChangeLog index aedde4bdd4..7a757e734d 100644 --- a/ChangeLog +++ b/ChangeLog @@ -27,12 +27,15 @@ Release date: TBA * Include modname in AST warnings. Useful for ``invalid escape sequence`` warnings with Python 3.12. +* ``RecursionError`` is now trapped and logged out as ``UserWarning`` during astroid node transformations with instructions about raising the system recursion limit. + + Closes pylint-dev/pylint#8842 + * Suppress ``SyntaxWarning`` for invalid escape sequences on Python 3.12 when parsing modules. Closes pylint-dev/pylint#9322 - What's New in astroid 3.0.3? ============================ Release date: 2024-02-04 diff --git a/astroid/nodes/as_string.py b/astroid/nodes/as_string.py index 3200061a47..f36115ff6e 100644 --- a/astroid/nodes/as_string.py +++ b/astroid/nodes/as_string.py @@ -6,6 +6,7 @@ from __future__ import annotations +import warnings from collections.abc import Iterator from typing import TYPE_CHECKING @@ -363,7 +364,15 @@ def visit_generatorexp(self, node) -> str: def visit_attribute(self, node) -> str: """return an astroid.Getattr node as string""" - left = self._precedence_parens(node, node.expr) + try: + left = self._precedence_parens(node, node.expr) + except RecursionError: + warnings.warn( + "Recursion limit exhausted; defaulting to adding parentheses.", + UserWarning, + stacklevel=2, + ) + left = f"({node.expr.accept(self)})" if left.isdigit(): left = f"({left})" return f"{left}.{node.attrname}" diff --git a/astroid/transforms.py b/astroid/transforms.py index c6fe51170a..0d9c22e966 100644 --- a/astroid/transforms.py +++ b/astroid/transforms.py @@ -4,6 +4,7 @@ from __future__ import annotations +import warnings from collections import defaultdict from collections.abc import Callable from typing import TYPE_CHECKING, List, Optional, Tuple, TypeVar, Union, cast, overload @@ -110,7 +111,18 @@ def _visit_generic(self, node: _Vistables) -> _VisitReturns: if not node or isinstance(node, str): return node - return self._visit(node) + try: + return self._visit(node) + except RecursionError: + # Returning the node untransformed is better than giving up. + warnings.warn( + f"Astroid was unable to transform {node}.\n" + "Some functionality will be missing unless the system recursion limit is lifted.\n" + "From pylint, try: --init-hook='import sys; sys.setrecursionlimit(2000)' or higher.", + UserWarning, + stacklevel=0, + ) + return node def register_transform( self, diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 6ea25fd846..c5605a9328 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -29,7 +29,7 @@ transforms, util, ) -from astroid.const import PY310_PLUS, PY312_PLUS, Context +from astroid.const import IS_PYPY, PY310_PLUS, PY312_PLUS, Context from astroid.context import InferenceContext from astroid.exceptions import ( AstroidBuildingError, @@ -47,6 +47,7 @@ Tuple, ) from astroid.nodes.scoped_nodes import ClassDef, FunctionDef, GeneratorExp, Module +from tests.testdata.python3.recursion_error import LONG_CHAINED_METHOD_CALL from . import resources @@ -279,6 +280,20 @@ def test_as_string_unknown() -> None: assert nodes.Unknown().as_string() == "Unknown.Unknown()" assert nodes.Unknown(lineno=1, col_offset=0).as_string() == "Unknown.Unknown()" + @staticmethod + @pytest.mark.skipif( + IS_PYPY, + reason="Test requires manipulating the recursion limit, which cannot " + "be undone in a finally block without polluting other tests on PyPy.", + ) + def test_recursion_error_trapped() -> None: + with pytest.warns(UserWarning, match="unable to transform"): + ast = abuilder.string_build(LONG_CHAINED_METHOD_CALL) + + attribute = ast.body[1].value.func + with pytest.raises(UserWarning): + attribute.as_string() + @pytest.mark.skipif(not PY312_PLUS, reason="Uses 3.12 type param nodes") class AsStringTypeParamNodes(unittest.TestCase): diff --git a/tests/test_transforms.py b/tests/test_transforms.py index 59aaf2100d..460868e02b 100644 --- a/tests/test_transforms.py +++ b/tests/test_transforms.py @@ -5,14 +5,20 @@ from __future__ import annotations import contextlib +import sys import time import unittest from collections.abc import Callable, Iterator +import pytest + from astroid import MANAGER, builder, nodes, parse, transforms +from astroid.brain.brain_dataclasses import _looks_like_dataclass_field_call +from astroid.const import IS_PYPY from astroid.manager import AstroidManager from astroid.nodes.node_classes import Call, Compare, Const, Name from astroid.nodes.scoped_nodes import FunctionDef, Module +from tests.testdata.python3.recursion_error import LONG_CHAINED_METHOD_CALL @contextlib.contextmanager @@ -258,3 +264,21 @@ def transform_class(cls): import UserDict """ ) + + def test_transform_aborted_if_recursion_limited(self): + def transform_call(node: Call) -> Const: + return node + + self.transformer.register_transform( + nodes.Call, transform_call, _looks_like_dataclass_field_call + ) + + original_limit = sys.getrecursionlimit() + sys.setrecursionlimit(500 if IS_PYPY else 1000) + + try: + with pytest.warns(UserWarning) as records: + self.parse_transform(LONG_CHAINED_METHOD_CALL) + assert "sys.setrecursionlimit" in records[0].message.args[0] + finally: + sys.setrecursionlimit(original_limit) diff --git a/tests/testdata/python3/recursion_error.py b/tests/testdata/python3/recursion_error.py new file mode 100644 index 0000000000..770da04038 --- /dev/null +++ b/tests/testdata/python3/recursion_error.py @@ -0,0 +1,170 @@ +LONG_CHAINED_METHOD_CALL = """ +from a import b + +( + b.builder('name') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .add('name', value='value') + .Build() +)"""