• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pyta-uoft / pyta / 18435062678

11 Oct 2025 09:36PM UTC coverage: 94.19% (-0.1%) from 94.292%
18435062678

Pull #1245

github

web-flow
Merge da63c6961 into 78ffa023d
Pull Request #1245: Refactor `setendings.py` to rely on built-in end-location attributes and reduce custom logic

1 of 1 new or added line in 1 file covered. (100.0%)

9 existing lines in 1 file now uncovered.

3469 of 3683 relevant lines covered (94.19%)

17.91 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.08
/python_ta/transforms/setendings.py
1
"""
2
Top-level functions to mutate the astroid nodes with `end_col_offset` and
3
`end_lineno` properties.
4

5
Where possible, the `end_col_offset` property is set by that of the node's last child.
6

7
    fromlineno
8
        - existing attribute
9
        - one-indexed
10
    end_lineno
11
        - new attribute
12
        - one-indexed
13
    col_offset
14
        - existing attribute
15
        - zero-indexed
16
        - located left of the first character
17
    end_col_offset
18
        - new attribute
19
        - zero-indexed
20
        - located right of the last character (essentially the string length)
21

22
In astroid/astroid/transforms.py, functions are registered to types in the
23
`transforms` dictionary in the TransformVisitor class. The traversal at
24
line 83 eventually leads to the transform called on each node at line 36,
25
within the _transform method.
26

27
Astroid Source:
28
https://github.com/PyCQA/astroid/blob/master/astroid/transforms.py
29
"""
30

31
from astroid import nodes
20✔
32
from astroid.transforms import TransformVisitor
20✔
33

34
CONSUMABLES = " \n\t\\"
20✔
35

36
# These nodes have a child, and their end_lineno and end_col_offset
37
# attributes are set equal to those of their last child.
38
NODES_WITH_CHILDREN = [
20✔
39
    nodes.Call,
40
    nodes.Comprehension,
41
    nodes.Module,
42
]
43

44

45
# Predicate functions, for setting locations based on source code.
46
# Predicates can only return a single truthy value, because of how its used in
47
# `astroid/transforms.py`
48
# ====================================================
49
def _token_search(token):
20✔
50
    """
51
    @type token: string
52
    @rtype: function
53
    """
54

55
    def _is_token(s, index, node):
20✔
56
        """Fix to include certain tokens such as a paren, bracket, or brace.
57
        @type s: string
58
        @type index: int
59
        @type node: Astroid node
60
        @rtype: bool
61
        """
62
        return s[index] == token
20✔
63

64
    return _is_token
20✔
65

66

67
def _keyword_search(keyword):
20✔
68
    """
69
    @type keyword: string
70
    @rtype: function
71
    """
72

73
    def _is_keyword(s, index, node):
20✔
74
        """Search for a keyword. Right-to-left.
75
        @type s: string
76
        @type index: int
77
        @type node: Astroid node
78
        @rtype: bool
79
        """
80
        return s[index : index + len(keyword)] == keyword
20✔
81

82
    return _is_keyword
20✔
83

84

85
def _is_arg_name(s, index, node):
20✔
86
    """Search for the name of the argument. Right-to-left."""
UNCOV
87
    if not node.arg:
×
UNCOV
88
        return False
×
UNCOV
89
    return s[index : index + len(node.arg)] == node.arg
×
90

91

92
# Nodes the require the source code for proper location setting
93
# Elements here are in the form
94
# (node class, predicate for start | None, predicate for end | None)
95
NODES_REQUIRING_SOURCE = [
20✔
96
    (nodes.Call, None, _token_search(")")),
97
    (nodes.DelAttr, _keyword_search("del"), None),
98
    (nodes.DelName, _keyword_search("del"), None),
99
    (nodes.GeneratorExp, _token_search("("), _token_search(")")),
100
    (nodes.List, _token_search("["), _token_search("]")),
101
    (nodes.Slice, _token_search("["), None),
102
    (nodes.Tuple, None, _token_search(",")),
103
]
104

105

106
def init_register_ending_setters(source_code):
20✔
107
    """Instantiate a visitor to transform the nodes.
108
    Register the transform functions on an instance of TransformVisitor.
109

110
    @type source_code: list of strings
111
    @rtype: TransformVisitor
112
    """
113
    ending_transformer = TransformVisitor()
20✔
114

115
    # Check consistency of astroid-provided fromlineno and col_offset attributes.
116
    for node_class in nodes.ALL_NODE_CLASSES:
20✔
117
        ending_transformer.register_transform(
20✔
118
            node_class,
119
            fix_start_attributes,
120
            lambda node: (
121
                getattr(node, "fromlineno", None) is None
122
                or getattr(node, "col_offset", None) is None
123
            ),
124
        )
125

126
    # Ad hoc transformations
127
    ending_transformer.register_transform(nodes.BinOp, _set_start_from_first_child)
20✔
128
    ending_transformer.register_transform(nodes.ClassDef, _set_start_from_first_decorator)
20✔
129
    ending_transformer.register_transform(nodes.FunctionDef, _set_start_from_first_decorator)
20✔
130
    ending_transformer.register_transform(nodes.Tuple, _set_start_from_first_child)
20✔
131
    ending_transformer.register_transform(nodes.Arguments, fix_arguments(source_code))
20✔
132
    ending_transformer.register_transform(nodes.Slice, fix_slice(source_code))
20✔
133

134
    for node_class in NODES_WITH_CHILDREN:
20✔
135
        ending_transformer.register_transform(node_class, set_from_last_child)
20✔
136

137
    # Nodes where the source code must also be provided.
138
    # source_code and the predicate functions get stored in the TransformVisitor
139
    for node_class, start_pred, end_pred in NODES_REQUIRING_SOURCE:
20✔
140
        if start_pred is not None:
20✔
141
            ending_transformer.register_transform(
20✔
142
                node_class, start_setter_from_source(source_code, start_pred)
143
            )
144
        if end_pred is not None:
20✔
145
            # This is for searching for a trailing comma after a tuple's final element
146
            if node_class is nodes.Tuple:
20✔
147
                ending_transformer.register_transform(
20✔
148
                    node_class, end_setter_from_source(source_code, end_pred, True)
149
                )
150
            else:
151
                ending_transformer.register_transform(
20✔
152
                    node_class, end_setter_from_source(source_code, end_pred)
153
                )
154

155
    # Nodes where extra parentheses are included
156
    ending_transformer.register_transform(nodes.BinOp, add_parens(source_code))
20✔
157
    ending_transformer.register_transform(nodes.Const, add_parens(source_code))
20✔
158
    ending_transformer.register_transform(nodes.Tuple, add_parens(source_code))
20✔
159

160
    return ending_transformer
20✔
161

162

163
# Transform functions.
164
# These functions are called on individual nodes to either fix the
165
# `fromlineno` and `col_offset` properties of the nodes,
166
# or to set the `end_lineno` and `end_col_offset` attributes for a node.
167
# ====================================================
168
def fix_slice(source_code):
20✔
169
    """
170
    The Slice node column positions are mostly set properly when it has (Const)
171
    children. The main problem is when Slice node doesn't have children.
172
    E.g "[:]", "[::]", "[:][:]", "[::][::]", ... yikes! The existing positions
173
    are sometimes set improperly to 0.
174
    """
175

176
    def _find_square_brackets(node):
20✔
177
        if _get_last_child(node):
20✔
178
            set_from_last_child(node)
20✔
179
            line_i = node.end_lineno - 1  # convert 1 to 0 index.
20✔
180
            char_i = node.end_col_offset
20✔
181
            has_children = True
20✔
182
        else:
183
            line_i = node.parent.value.end_lineno - 1  # convert 1 to 0 index.
20✔
184
            char_i = node.parent.value.end_col_offset
20✔
185
            has_children = False
20✔
186

187
        # Search the remaining source code for the "]" char.
188
        while (
20✔
189
            line_i < len(source_code)
190
            and char_i < len(source_code[line_i])
191
            and source_code[line_i][char_i] != "]"
192
        ):
193
            if char_i == len(source_code[line_i]) - 1 or source_code[line_i][char_i] == "#":
20✔
194
                char_i = 0
×
195
                line_i += 1
×
196
            else:
197
                char_i += 1
20✔
198

199
        if not has_children:
20✔
200
            node.fromlineno, node.col_offset = line_i + 1, char_i
20✔
201
        node.end_lineno, node.end_col_offset = line_i + 1, char_i + 1
20✔
202

203
        return node
20✔
204

205
    return _find_square_brackets
20✔
206

207

208
def fix_arguments(source_code):
20✔
209
    """For an Arguments node"""
210

211
    def _find(node: nodes.Arguments) -> nodes.Arguments:
20✔
212
        children = list(node.get_children())
20✔
213
        if children:
20✔
214
            fix_start_attributes(node)
20✔
215

216
        line_i = node.parent.fromlineno
20✔
217
        char_i = node.parent.col_offset
20✔
218
        for child in children:
20✔
219
            if line_i is None:
20✔
220
                line_i = child.end_lineno
×
221
                char_i = child.end_col_offset
×
222
            elif (
20✔
223
                line_i < child.end_lineno
224
                or line_i == child.end_lineno
225
                and char_i < child.end_col_offset
226
            ):
227
                line_i = child.end_lineno
20✔
228
                char_i = child.end_col_offset
20✔
229

230
        line_i -= 1  # Switch to 0-indexing
20✔
231

232
        # left bracket if parent is FunctionDef, colon if Lambda
233
        if isinstance(node.parent, nodes.FunctionDef):
20✔
234
            end_char = ")"
20✔
235
        else:
236
            end_char = ":"
20✔
237

238
        while char_i < len(source_code[line_i]) and source_code[line_i][char_i] != end_char:
20✔
239
            if char_i == len(source_code[line_i]) - 1 or source_code[line_i][char_i] == "#":
20✔
240
                char_i = 0
20✔
241
                line_i += 1
20✔
242
            else:
243
                char_i += 1
20✔
244

245
        node.end_lineno, node.end_col_offset = line_i + 1, char_i
20✔
246

247
        # no children
248
        if children == []:
20✔
249
            node.fromlineno, node.col_offset = line_i + 1, char_i
20✔
250

251
        return node
20✔
252

253
    return _find
20✔
254

255

256
def fix_start_attributes(node):
20✔
257
    """Some nodes don't always have the `col_offset` property set by Astroid:
258
    Comprehension, Keyword, Module, Slice.
259
    """
260
    try:
20✔
261
        first_child = next(node.get_children())
20✔
262
        if getattr(node, "fromlineno", None) is None:
20✔
263
            node.fromlineno = first_child.fromlineno
×
264
        if getattr(node, "col_offset", None) is None:
20✔
265
            node.col_offset = first_child.col_offset
20✔
266

267
    except StopIteration:
20✔
268
        # No children. Go to the enclosing statement and use that.
269
        # This assumes that statement nodes will always have these attributes set.
270
        statement = node.statement()
20✔
271
        if statement is not node:
20✔
272
            if getattr(node, "fromlineno", None) is None:
20✔
273
                node.fromlineno = statement.fromlineno
×
274
            if getattr(node, "col_offset", None) is None:
20✔
275
                node.col_offset = statement.col_offset
20✔
276
        else:
277
            # Enclosing statement is same as node, also does not have attributes set
278
            if getattr(node, "fromlineno", None) is None:
×
279
                node.fromlineno = 0
×
280
            if getattr(node, "col_offset", None) is None:
×
281
                node.col_offset = 0
×
282
    return node
20✔
283

284

285
def _set_start_from_first_child(node):
20✔
286
    """Set the start attributes of this node from its first child."""
287
    try:
20✔
288
        first_child = next(node.get_children())
20✔
289
    except StopIteration:
20✔
290
        pass
20✔
291
    else:
292
        node.fromlineno = first_child.fromlineno
20✔
293
        node.col_offset = first_child.col_offset
20✔
294
    return node
20✔
295

296

297
def _set_start_from_first_decorator(node):
20✔
298
    """Set the start attributes of this node from its first child, if that child is a decorator."""
299
    if getattr(node, "decorators"):
20✔
300
        first_child = node.decorators
20✔
301
        node.fromlineno = first_child.fromlineno
20✔
302
        node.col_offset = first_child.col_offset
20✔
303
    return node
20✔
304

305

306
def set_from_last_child(node):
20✔
307
    """Populate ending locations for astroid node based on its last child.
308

309
    Preconditions:
310
      - `node` must have a `last_child` (node).
311
      - `node` has col_offset property set.
312
    """
313
    last_child = _get_last_child(node)
20✔
314
    if not last_child:
20✔
315
        return node
20✔
316

317
    if last_child.end_lineno is not None:
20✔
318
        node.end_lineno = last_child.end_lineno
20✔
319
    if last_child.end_col_offset is not None:
20✔
320
        node.end_col_offset = last_child.end_col_offset
20✔
321
    return node
20✔
322

323

324
def _get_last_child(node):
20✔
325
    """Returns the last child node, or None.
326
    Some nodes' last_child() attribute not set, e.g. nodes.Arguments.
327
    """
328
    if node.last_child():
20✔
329
        return node.last_child()
20✔
330
    else:
331
        # Get the first child from the `get_children` generator.
332
        skip_to_last_child = None  # save reference to last child.
20✔
333
        for skip_to_last_child in node.get_children():
20✔
334
            pass  # skip to last
×
335
        return skip_to_last_child  # postcondition: node, or None.
20✔
336

337

338
def end_setter_from_source(source_code, pred, only_consumables=False):
20✔
339
    """Returns a *function* that sets ending locations for a node from source.
340

341
    The basic technique is to do the following:
342
      1. Find the ending locations for the node based on its last child.
343
      2. Starting at that point, iterate through characters in the source code
344
         up to and including the first index that satisfies pred.
345

346
    pred is a function that takes a string and index and returns a bool,
347
    e.g. _is_close_paren
348

349
    If only_consumables is True, the search halts when it reaches a non-consumable
350
    character that fails pred *on the first line*.
351
    TODO: really the behaviour should be the same for all lines searched for.
352
    """
353

354
    def set_endings_from_source(node):
20✔
355
        # Tuple nodes have an end_col_offset that includes the end paren,
356
        # but their col_offset does not include the start paren.
357
        # To address this, we override the Tuple node's end_col_offset.
358
        if isinstance(node, nodes.Tuple):
20✔
359
            set_from_last_child(node)
20✔
360

361
        # Initialize counters. Note: we need to offset lineno,
362
        # since it's 1-indexed.
363
        end_col_offset, lineno = node.end_col_offset, node.end_lineno - 1
20✔
364

365
        # First, search the remaining part of the current end line.
366
        for j in range(end_col_offset, len(source_code[lineno])):
20✔
367
            if source_code[lineno][j] == "#":
20✔
368
                break  # skip over comment lines
20✔
369
            if pred(source_code[lineno], j, node):
20✔
370
                node.end_col_offset = j + 1
20✔
371
                return node
20✔
372
            elif only_consumables and source_code[lineno][j] not in CONSUMABLES:
20✔
373
                return node
20✔
374

375
        # If that doesn't work, search remaining lines
376
        for i in range(lineno + 1, len(source_code)):
20✔
377
            # Search each character
378
            for j in range(len(source_code[i])):
20✔
379
                if source_code[i][j] == "#":
20✔
380
                    break  # skip over comment lines
20✔
381
                if pred(source_code[i], j, node):
20✔
382
                    node.end_col_offset, node.end_lineno = j + 1, i + 1
20✔
383
                    return node
20✔
384
                # only consume inert characters.
385
                elif source_code[i][j] not in CONSUMABLES:
20✔
386
                    return node
20✔
387
        return node
20✔
388

389
    return set_endings_from_source
20✔
390

391

392
def start_setter_from_source(source_code, pred):
20✔
393
    """Returns a *function* that sets start locations for a node from source.
394
    Recall `source_code`, `pred` are within the lexical scope of the returned function.
395

396
    The basic technique is to do the following:
397
      1. Find the start locations for the node (already set).
398
      2. Starting at that point, iterate through characters in the source code
399
         in reverse until reaching the first index that satisfies pred.
400

401
    pred is a function that takes a string and index and returns a bool,
402
    e.g. _is_open_paren
403
    """
404

405
    def set_start_from_source(node):
20✔
406
        # Initialize counters. Note: fromlineno is 1-indexed.
407
        col_offset, lineno = node.col_offset, node.fromlineno - 1
20✔
408

409
        # First, search the remaining part of the current start line
410
        for j in range(min(len(source_code[lineno]) - 1, col_offset), -1, -1):
20✔
411
            if pred(source_code[lineno], j, node):
20✔
412
                node.col_offset = j
20✔
413
                return node
20✔
414

415
        # If that doesn't work, search remaining lines
UNCOV
416
        for i in range(lineno - 1, -1, -1):
×
417
            # Search each character, right-to-left
UNCOV
418
            for j in range(len(source_code[i]) - 1, -1, -1):
×
UNCOV
419
                if pred(source_code[i], j, node):
×
420
                    node.end_col_offset, node.end_lineno = j, i + 1
×
421
                    return node
×
422
                # only consume inert characters.
UNCOV
423
                elif source_code[i][j] not in CONSUMABLES:
×
UNCOV
424
                    return node
×
UNCOV
425
        return node
×
426

427
    return set_start_from_source
20✔
428

429

430
def add_parens(source_code):
20✔
431
    def h(node):
20✔
432
        _add_parens(source_code)(node)
20✔
433

434
    return h
20✔
435

436

437
def _add_parens(source_code):
20✔
438
    def h(node):
20✔
439
        # Initialize counters. Note: fromlineno is 1-indexed.
440
        prev = node.fromlineno, node.col_offset, node.end_lineno, node.end_col_offset
20✔
441
        while True:
16✔
442
            col_offset, lineno = node.col_offset, node.fromlineno - 1
20✔
443
            end_col_offset, end_lineno = node.end_col_offset, node.end_lineno - 1
20✔
444

445
            # First, search the remaining part of the current start line
446
            prev_char, new_lineno, new_coloffset = None, None, None
20✔
447
            for j in range(col_offset - 1, -1, -1):
20✔
448
                if source_code[lineno][j] in CONSUMABLES or source_code[lineno][j] == ",":
20✔
449
                    continue
20✔
450
                else:
451
                    prev_char, new_lineno, new_coloffset = source_code[lineno][j], lineno, j
20✔
452
                    break
20✔
453

454
            if prev_char is None:
20✔
455
                # Search remaining lines
456
                for i in range(lineno - 1, -1, -1):
20✔
457
                    # Search each character, right-to-left
458
                    for j in range(len(source_code[i]) - 1, -1, -1):
20✔
459
                        if source_code[i][j] in CONSUMABLES or source_code[i][j] == ",":
20✔
460
                            continue
20✔
461
                        else:
462
                            prev_char, new_lineno, new_coloffset = source_code[i][j], i, j
20✔
463

464
                            break
20✔
465
                    if prev_char is not None:
20✔
466
                        break
20✔
467

468
            if prev_char != "(":
20✔
469
                # No enclosing parentheses
470
                break
20✔
471

472
            # Now search for matching ')'
473
            next_char, new_end_lineno, new_end_coloffset = None, None, None
20✔
474
            for j in range(end_col_offset, len(source_code[end_lineno])):
20✔
475
                if source_code[end_lineno][j] == "#":
20✔
476
                    break  # skip over comment lines
×
477
                elif source_code[end_lineno][j] in CONSUMABLES:
20✔
478
                    continue
20✔
479
                else:
480
                    next_char, new_end_lineno, new_end_coloffset = (
20✔
481
                        source_code[end_lineno][j],
482
                        end_lineno,
483
                        j,
484
                    )
485
                    break
20✔
486

487
            if next_char is None:
20✔
488
                # Search remaining lines
489
                for i in range(end_lineno + 1, len(source_code)):
20✔
490
                    # Search each character
491
                    for j in range(len(source_code[i])):
20✔
492
                        if source_code[i][j] == "#":
20✔
493
                            break  # skip over comment lines
×
494
                        elif source_code[i][j] in CONSUMABLES:
20✔
495
                            continue
20✔
496
                        else:
497
                            next_char, new_end_lineno, new_end_coloffset = source_code[i][j], i, j
20✔
498
                            break
20✔
499
                    if next_char is not None:
20✔
500
                        break
20✔
501

502
            if next_char != ")":
20✔
503
                break
20✔
504

505
            # At this point, an enclosing pair of parentheses has been found
506
            prev = node.fromlineno, node.col_offset, node.end_lineno, node.end_col_offset
20✔
507
            node.fromlineno, node.col_offset, node.end_lineno, node.end_col_offset = (
20✔
508
                new_lineno + 1,
509
                new_coloffset,
510
                new_end_lineno + 1,
511
                new_end_coloffset + 1,
512
            )
513

514
        # Go back by 1 set of parentheses if inside a function call.
515
        if isinstance(node.parent, nodes.Call) and len(node.parent.args) == 1:
20✔
516
            node.fromlineno, node.col_offset, node.end_lineno, node.end_col_offset = prev
20✔
517

518
        return node
20✔
519

520
    return h
20✔
521

522

523
# Make this module a pylint plugin
524
def register(linter):
20✔
525
    """Patch linter to apply message transform with source code."""
526
    old_get_ast = linter.get_ast
20✔
527

528
    def new_get_ast(filepath, modname, data):
20✔
529
        ast = old_get_ast(filepath, modname, data)
20✔
530
        if ast is not None:
20✔
531
            with open(filepath, encoding="utf-8") as f:
20✔
532
                source_code = f.readlines()
20✔
533
            ending_transformer = init_register_ending_setters(source_code)
20✔
534
            ending_transformer.visit(ast)
20✔
535
        return ast
20✔
536

537
    linter.get_ast = new_get_ast
20✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc