Skip to content

Commit a62f37d

Browse files
authored
Add position attribute for nodes (pylint-dev#1393)
1 parent 514c832 commit a62f37d

File tree

8 files changed

+291
-9
lines changed

8 files changed

+291
-9
lines changed

ChangeLog

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ Release date: TBA
5353

5454
Closes #1085
5555

56+
* Add optional ``NodeNG.position`` attribute.
57+
Used for block nodes to highlight position of keyword(s) and name
58+
in cases where the AST doesn't provide good enough positional information.
59+
E.g. ``nodes.ClassDef``, ``nodes.FunctionDef``.
60+
5661
* Fix ``ClassDef.fromlineno``. For Python < 3.8 the ``lineno`` attribute includes decorators.
5762
``fromlineno`` should return the line of the ``class`` statement itself.
5863

astroid/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@
4343
* builder contains the class responsible to build astroid trees
4444
"""
4545

46+
import functools
47+
import tokenize
4648
from importlib import import_module
4749
from pathlib import Path
4850

@@ -60,7 +62,7 @@
6062
from astroid.bases import BaseInstance, BoundMethod, Instance, UnboundMethod
6163
from astroid.brain.helpers import register_module_extender
6264
from astroid.builder import extract_node, parse
63-
from astroid.const import Context, Del, Load, Store
65+
from astroid.const import PY310_PLUS, Context, Del, Load, Store
6466
from astroid.exceptions import *
6567
from astroid.inference_tip import _inference_tip_cached, inference_tip
6668
from astroid.objects import ExceptionInstance
@@ -165,6 +167,15 @@
165167

166168
from astroid.util import Uninferable
167169

170+
# Performance hack for tokenize. See https://bugs.python.org/issue43014
171+
# Adapted from https://github.com/PyCQA/pycodestyle/pull/993
172+
if (
173+
not PY310_PLUS
174+
and callable(getattr(tokenize, "_compile", None))
175+
and getattr(tokenize._compile, "__wrapped__", None) is None # type: ignore[attr-defined]
176+
):
177+
tokenize._compile = functools.lru_cache()(tokenize._compile) # type: ignore[attr-defined]
178+
168179
# load brain plugins
169180
ASTROID_INSTALL_DIRECTORY = Path(__file__).parent
170181
BRAIN_MODULES_DIRECTORY = ASTROID_INSTALL_DIRECTORY / "brain"

astroid/builder.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ def _post_build(self, module, encoding):
175175
module = self._manager.visit_transforms(module)
176176
return module
177177

178-
def _data_build(self, data, modname, path):
178+
def _data_build(self, data: str, modname, path):
179179
"""Build tree node from data and add some information"""
180180
try:
181181
node, parser_module = _parse_string(data, type_comments=True)
@@ -200,7 +200,7 @@ def _data_build(self, data, modname, path):
200200
path is not None
201201
and os.path.splitext(os.path.basename(path))[0] == "__init__"
202202
)
203-
builder = rebuilder.TreeRebuilder(self._manager, parser_module)
203+
builder = rebuilder.TreeRebuilder(self._manager, parser_module, data)
204204
module = builder.visit_module(node, modname, node_file, package)
205205
module._import_from_nodes = builder._import_from_nodes
206206
module._delayed_assattr = builder._delayed_assattr

astroid/nodes/node_ng.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from astroid.manager import AstroidManager
2929
from astroid.nodes.as_string import AsStringVisitor
3030
from astroid.nodes.const import OP_PRECEDENCE
31+
from astroid.nodes.utils import Position
3132

3233
if TYPE_CHECKING:
3334
from astroid import nodes
@@ -118,6 +119,12 @@ def __init__(
118119
Note: This is after the last symbol.
119120
"""
120121

122+
self.position: Optional[Position] = None
123+
"""Position of keyword(s) and name. Used as fallback for block nodes
124+
which might not provide good enough positional information.
125+
E.g. ClassDef, FunctionDef.
126+
"""
127+
121128
def infer(self, context=None, **kwargs):
122129
"""Get a generator of the inferred values.
123130

astroid/nodes/scoped_nodes/scoped_nodes.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
from astroid.interpreter.objectmodel import ClassModel, FunctionModel, ModuleModel
7979
from astroid.manager import AstroidManager
8080
from astroid.nodes import Arguments, Const, node_classes
81+
from astroid.nodes.utils import Position
8182

8283
if sys.version_info >= (3, 6, 2):
8384
from typing import NoReturn
@@ -1490,7 +1491,7 @@ class FunctionDef(mixins.MultiLineBlockMixin, node_classes.Statement, Lambda):
14901491
type_comment_returns = None
14911492
"""If present, this will contain the return type annotation, passed by a type comment"""
14921493
# attributes below are set by the builder module or by raw factories
1493-
_other_fields = ("name", "doc")
1494+
_other_fields = ("name", "doc", "position")
14941495
_other_other_fields = (
14951496
"locals",
14961497
"_type",
@@ -1567,6 +1568,8 @@ def postinit(
15671568
returns=None,
15681569
type_comment_returns=None,
15691570
type_comment_args=None,
1571+
*,
1572+
position: Optional[Position] = None,
15701573
):
15711574
"""Do some setup after initialisation.
15721575
@@ -1582,13 +1585,16 @@ def postinit(
15821585
The return type annotation passed via a type comment.
15831586
:params type_comment_args:
15841587
The args type annotation passed via a type comment.
1588+
:params position:
1589+
Position of function keyword(s) and name.
15851590
"""
15861591
self.args = args
15871592
self.body = body
15881593
self.decorators = decorators
15891594
self.returns = returns
15901595
self.type_comment_returns = type_comment_returns
15911596
self.type_comment_args = type_comment_args
1597+
self.position = position
15921598

15931599
@decorators_mod.cachedproperty
15941600
def extra_decorators(self) -> List[node_classes.Call]:
@@ -2131,7 +2137,7 @@ def my_meth(self, arg):
21312137
":type: str"
21322138
),
21332139
)
2134-
_other_fields = ("name", "doc", "is_dataclass")
2140+
_other_fields = ("name", "doc", "is_dataclass", "position")
21352141
_other_other_fields = ("locals", "_newstyle")
21362142
_newstyle = None
21372143

@@ -2241,7 +2247,15 @@ def implicit_locals(self):
22412247

22422248
# pylint: disable=redefined-outer-name
22432249
def postinit(
2244-
self, bases, body, decorators, newstyle=None, metaclass=None, keywords=None
2250+
self,
2251+
bases,
2252+
body,
2253+
decorators,
2254+
newstyle=None,
2255+
metaclass=None,
2256+
keywords=None,
2257+
*,
2258+
position: Optional[Position] = None,
22452259
):
22462260
"""Do some setup after initialisation.
22472261
@@ -2262,6 +2276,8 @@ def postinit(
22622276
22632277
:param keywords: The keywords given to the class definition.
22642278
:type keywords: list(Keyword) or None
2279+
2280+
:param position: Position of class keyword and name.
22652281
"""
22662282
if keywords is not None:
22672283
self.keywords = keywords
@@ -2272,6 +2288,7 @@ def postinit(
22722288
self._newstyle = newstyle
22732289
if metaclass is not None:
22742290
self._metaclass = metaclass
2291+
self.position = position
22752292

22762293
def _newstyle_impl(self, context=None):
22772294
if context is None:

astroid/nodes/utils.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from typing import NamedTuple
2+
3+
4+
class Position(NamedTuple):
5+
"""Position with line and column information."""
6+
7+
lineno: int
8+
col_offset: int
9+
end_lineno: int
10+
end_col_offset: int

astroid/rebuilder.py

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@
3131
"""
3232

3333
import sys
34+
import token
35+
from io import StringIO
36+
from tokenize import TokenInfo, generate_tokens
3437
from typing import (
3538
TYPE_CHECKING,
3639
Callable,
@@ -48,9 +51,10 @@
4851

4952
from astroid import nodes
5053
from astroid._ast import ParserModule, get_parser_module, parse_function_type_comment
51-
from astroid.const import PY38, PY38_PLUS, Context
54+
from astroid.const import PY36, PY38, PY38_PLUS, Context
5255
from astroid.manager import AstroidManager
5356
from astroid.nodes import NodeNG
57+
from astroid.nodes.utils import Position
5458

5559
if sys.version_info >= (3, 8):
5660
from typing import Final
@@ -88,9 +92,13 @@ class TreeRebuilder:
8892
"""Rebuilds the _ast tree to become an Astroid tree"""
8993

9094
def __init__(
91-
self, manager: AstroidManager, parser_module: Optional[ParserModule] = None
92-
):
95+
self,
96+
manager: AstroidManager,
97+
parser_module: Optional[ParserModule] = None,
98+
data: Optional[str] = None,
99+
) -> None:
93100
self._manager = manager
101+
self._data = data.split("\n") if data else None
94102
self._global_names: List[Dict[str, List[nodes.Global]]] = []
95103
self._import_from_nodes: List[nodes.ImportFrom] = []
96104
self._delayed_assattr: List[nodes.AssignAttr] = []
@@ -133,6 +141,68 @@ def _get_context(
133141
) -> Context:
134142
return self._parser_module.context_classes.get(type(node.ctx), Context.Load)
135143

144+
def _get_position_info(
145+
self,
146+
node: Union["ast.ClassDef", "ast.FunctionDef", "ast.AsyncFunctionDef"],
147+
parent: Union[nodes.ClassDef, nodes.FunctionDef, nodes.AsyncFunctionDef],
148+
) -> Optional[Position]:
149+
"""Return position information for ClassDef and FunctionDef nodes.
150+
151+
In contrast to AST positions, these only include the actual keyword(s)
152+
and the class / function name.
153+
154+
>>> @decorator
155+
>>> async def some_func(var: int) -> None:
156+
>>> ^^^^^^^^^^^^^^^^^^^
157+
"""
158+
if not self._data:
159+
return None
160+
end_lineno: Optional[int] = getattr(node, "end_lineno", None)
161+
if node.body:
162+
end_lineno = node.body[0].lineno
163+
# pylint: disable-next=unsubscriptable-object
164+
data = "\n".join(self._data[node.lineno - 1 : end_lineno])
165+
166+
start_token: Optional[TokenInfo] = None
167+
keyword_tokens: Tuple[int, ...] = (token.NAME,)
168+
if isinstance(parent, nodes.AsyncFunctionDef):
169+
search_token = "async"
170+
if PY36:
171+
# In Python 3.6, the token type for 'async' was 'ASYNC'
172+
# In Python 3.7, the type was changed to 'NAME' and 'ASYNC' removed
173+
# Python 3.8 added it back. However, if we use it unconditionally
174+
# we would break 3.7.
175+
keyword_tokens = (token.NAME, token.ASYNC)
176+
elif isinstance(parent, nodes.FunctionDef):
177+
search_token = "def"
178+
else:
179+
search_token = "class"
180+
181+
for t in generate_tokens(StringIO(data).readline):
182+
if (
183+
start_token is not None
184+
and t.type == token.NAME
185+
and t.string == node.name
186+
):
187+
break
188+
if t.type in keyword_tokens:
189+
if t.string == search_token:
190+
start_token = t
191+
continue
192+
if t.string in {"def"}:
193+
continue
194+
start_token = None
195+
else:
196+
return None
197+
198+
# pylint: disable=undefined-loop-variable
199+
return Position(
200+
lineno=node.lineno - 1 + start_token.start[0],
201+
col_offset=start_token.start[1],
202+
end_lineno=node.lineno - 1 + t.end[0],
203+
end_col_offset=t.end[1],
204+
)
205+
136206
def visit_module(
137207
self, node: "ast.Module", modname: str, modpath: str, package: bool
138208
) -> nodes.Module:
@@ -1203,6 +1273,7 @@ def visit_classdef(
12031273
for kwd in node.keywords
12041274
if kwd.arg != "metaclass"
12051275
],
1276+
position=self._get_position_info(node, newnode),
12061277
)
12071278
return newnode
12081279

@@ -1551,6 +1622,7 @@ def _visit_functiondef(
15511622
returns=returns,
15521623
type_comment_returns=type_comment_returns,
15531624
type_comment_args=type_comment_args,
1625+
position=self._get_position_info(node, newnode),
15541626
)
15551627
self._global_names.pop()
15561628
return newnode

0 commit comments

Comments
 (0)