diff --git a/ChangeLog b/ChangeLog index bce6094b46..2b701d6304 100644 --- a/ChangeLog +++ b/ChangeLog @@ -44,6 +44,9 @@ Release date: TBA Closes #1085 +* Fix ``ClassDef.fromlineno``. For Python < 3.8 the ``lineno`` attribute includes decorators. + ``fromlineno`` should return the line of the ``class`` statement itself. + What's New in astroid 2.9.4? ============================ Release date: TBA diff --git a/astroid/nodes/scoped_nodes/scoped_nodes.py b/astroid/nodes/scoped_nodes/scoped_nodes.py index 300f8c3371..a9059a8e8e 100644 --- a/astroid/nodes/scoped_nodes/scoped_nodes.py +++ b/astroid/nodes/scoped_nodes/scoped_nodes.py @@ -55,7 +55,7 @@ from astroid import bases from astroid import decorators as decorators_mod from astroid import mixins, util -from astroid.const import PY39_PLUS +from astroid.const import PY38_PLUS, PY39_PLUS from astroid.context import ( CallContext, InferenceContext, @@ -1706,13 +1706,10 @@ def type( return type_name @decorators_mod.cachedproperty - def fromlineno(self): - """The first line that this node appears on in the source code. - - :type: int or None - """ + def fromlineno(self) -> Optional[int]: + """The first line that this node appears on in the source code.""" # lineno is the line number of the first decorator, we want the def - # statement lineno + # statement lineno. Similar to 'ClassDef.fromlineno' lineno = self.lineno if self.decorators is not None: lineno += sum( @@ -2300,6 +2297,21 @@ def _newstyle_impl(self, context=None): doc=("Whether this is a new style class or not\n\n" ":type: bool or None"), ) + @decorators_mod.cachedproperty + def fromlineno(self) -> Optional[int]: + """The first line that this node appears on in the source code.""" + if not PY38_PLUS: + # For Python < 3.8 the lineno is the line number of the first decorator. + # We want the class statement lineno. Similar to 'FunctionDef.fromlineno' + lineno = self.lineno + if self.decorators is not None: + lineno += sum( + node.tolineno - node.lineno + 1 for node in self.decorators.nodes + ) + + return lineno + return super().fromlineno + @decorators_mod.cachedproperty def blockstart_tolineno(self): """The line on which the beginning of this block ends. diff --git a/tests/unittest_builder.py b/tests/unittest_builder.py index b7e3739cc7..151abcbe02 100644 --- a/tests/unittest_builder.py +++ b/tests/unittest_builder.py @@ -33,6 +33,7 @@ import socket import sys import tempfile +import textwrap import unittest import pytest @@ -136,12 +137,54 @@ def function( __name__, ) function = astroid["function"] - # XXX discussable, but that's what is expected by pylint right now + # XXX discussable, but that's what is expected by pylint right now, similar to ClassDef self.assertEqual(function.fromlineno, 3) self.assertEqual(function.tolineno, 5) self.assertEqual(function.decorators.fromlineno, 2) self.assertEqual(function.decorators.tolineno, 2) + @staticmethod + def test_decorated_class_lineno() -> None: + code = textwrap.dedent( + """ + class A: + ... + + @decorator + class B: + ... + + @deco1 + @deco2( + var=42 + ) + class C: + ... + """ + ) + + ast_module: nodes.Module = builder.parse(code) # type: ignore[assignment] + + a = ast_module.body[0] + assert isinstance(a, nodes.ClassDef) + assert a.fromlineno == 2 + assert a.tolineno == 3 + + b = ast_module.body[1] + assert isinstance(b, nodes.ClassDef) + assert b.fromlineno == 6 + assert b.tolineno == 7 + + c = ast_module.body[2] + assert isinstance(c, nodes.ClassDef) + if not PY38_PLUS: + # Not perfect, but best we can do for Python 3.7 + # Can't detect closing bracket on new line. + assert c.fromlineno == 12 + else: + assert c.fromlineno == 13 + assert c.tolineno == 14 + def test_class_lineno(self) -> None: stmts = self.astroid.body # on line 20: