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

pyta-uoft / pyta / 21881976375

10 Feb 2026 08:52PM UTC coverage: 89.788% (-0.07%) from 89.854%
21881976375

Pull #1298

github

web-flow
Merge 81e2f2beb into f06042ded
Pull Request #1298: Fixed setendings.py bug on a subscript tuple containing slices

9 of 13 new or added lines in 1 file covered. (69.23%)

23 existing lines in 1 file now uncovered.

3429 of 3819 relevant lines covered (89.79%)

17.41 hits per line

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

89.03
/packages/python-ta/src/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."""
87
    if not node.arg:
×
88
        return False
×
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.Tuple, None, _token_search(",")),
102
]
103

104

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

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

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

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

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

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

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

159
    return ending_transformer
20✔
160

161

162
# Transform functions.
163
# These functions are called on individual nodes to either fix the
164
# `fromlineno` and `col_offset` properties of the nodes,
165
# or to set the `end_lineno` and `end_col_offset` attributes for a node.
166
# ====================================================
167
def fix_slice(source_code):
20✔
168
    """Fix the start location of the Slice node.
169
    By default it appears to always include the opening "[", so we modify this to either
170
    be the start location of the "lower" node if one exists, or to the starting ":" otherwise.
171
    """
172

173
    def _fix_slice_endings(node: nodes.Slice) -> nodes.Slice:
20✔
174
        if node.lower is not None:
20✔
175
            node.lineno, node.col_offset = node.lower.lineno, node.lower.col_offset
20✔
176
        else:
177
            # The start is the open square bracket. Search until the first colon is reached
178
            line_i = node.lineno - 1  # convert 1 to 0 index
20✔
179
            char_i = node.col_offset
20✔
180
            while (
20✔
181
                line_i < len(source_code)
182
                and char_i < len(source_code[line_i])
183
                and source_code[line_i][char_i] != ":"
184
            ):
NEW
UNCOV
185
                if char_i == len(source_code[line_i]) - 1 or source_code[line_i][char_i] == "#":
×
NEW
UNCOV
186
                    char_i = 0
×
NEW
UNCOV
187
                    line_i += 1
×
188
                else:
NEW
UNCOV
189
                    char_i += 1
×
190

191
            if line_i < len(source_code) and char_i < len(source_code[line_i]):
20✔
192
                node.lineno, node.col_offset = line_i + 1, char_i
20✔
193

194
        return node
20✔
195

196
    return _fix_slice_endings
20✔
197

198

199
def fix_arguments(source_code):
20✔
200
    """For an Arguments node"""
201

202
    def _find(node: nodes.Arguments) -> nodes.Arguments:
20✔
203
        children = list(node.get_children())
20✔
204
        if children:
20✔
205
            fix_start_attributes(node)
20✔
206

207
        line_i = node.parent.fromlineno
20✔
208
        char_i = node.parent.col_offset
20✔
209
        for child in children:
20✔
210
            if line_i is None:
20✔
UNCOV
211
                line_i = child.end_lineno
×
UNCOV
212
                char_i = child.end_col_offset
×
213
            elif (
20✔
214
                line_i < child.end_lineno
215
                or line_i == child.end_lineno
216
                and char_i < child.end_col_offset
217
            ):
218
                line_i = child.end_lineno
20✔
219
                char_i = child.end_col_offset
20✔
220

221
        line_i -= 1  # Switch to 0-indexing
20✔
222

223
        # left bracket if parent is FunctionDef, colon if Lambda
224
        if isinstance(node.parent, nodes.FunctionDef):
20✔
225
            end_char = ")"
20✔
226
        else:
227
            end_char = ":"
20✔
228

229
        while char_i < len(source_code[line_i]) and source_code[line_i][char_i] != end_char:
20✔
230
            if char_i == len(source_code[line_i]) - 1 or source_code[line_i][char_i] == "#":
20✔
231
                char_i = 0
20✔
232
                line_i += 1
20✔
233
            else:
234
                char_i += 1
20✔
235

236
        node.end_lineno, node.end_col_offset = line_i + 1, char_i
20✔
237

238
        # no children
239
        if children == []:
20✔
240
            node.fromlineno, node.col_offset = line_i + 1, char_i
20✔
241

242
        return node
20✔
243

244
    return _find
20✔
245

246

247
def fix_start_attributes(node):
20✔
248
    """Some nodes don't always have the `col_offset` property set by Astroid:
249
    Comprehension, Keyword, Module, Slice.
250
    """
251
    try:
20✔
252
        first_child = next(node.get_children())
20✔
253
        if getattr(node, "fromlineno", None) is None:
20✔
UNCOV
254
            node.fromlineno = first_child.fromlineno
×
255
        if getattr(node, "col_offset", None) is None:
20✔
256
            node.col_offset = first_child.col_offset
20✔
257

258
    except StopIteration:
20✔
259
        # No children. Go to the enclosing statement and use that.
260
        # This assumes that statement nodes will always have these attributes set.
261
        statement = node.statement()
20✔
262
        if statement is not node:
20✔
263
            if getattr(node, "fromlineno", None) is None:
20✔
UNCOV
264
                node.fromlineno = statement.fromlineno
×
265
            if getattr(node, "col_offset", None) is None:
20✔
266
                node.col_offset = statement.col_offset
20✔
267
        else:
268
            # Enclosing statement is same as node, also does not have attributes set
UNCOV
269
            if getattr(node, "fromlineno", None) is None:
×
UNCOV
270
                node.fromlineno = 0
×
UNCOV
271
            if getattr(node, "col_offset", None) is None:
×
UNCOV
272
                node.col_offset = 0
×
273
    return node
20✔
274

275

276
def _set_start_from_first_child(node):
20✔
277
    """Set the start attributes of this node from its first child."""
278
    try:
20✔
279
        first_child = next(node.get_children())
20✔
280
    except StopIteration:
20✔
281
        pass
20✔
282
    else:
283
        node.fromlineno = first_child.fromlineno
20✔
284
        node.col_offset = first_child.col_offset
20✔
285
    return node
20✔
286

287

288
def _set_start_from_first_decorator(node):
20✔
289
    """Set the start attributes of this node from its first child, if that child is a decorator."""
290
    if getattr(node, "decorators"):
20✔
291
        first_child = node.decorators
20✔
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_from_last_child(node):
20✔
298
    """Populate ending locations for astroid node based on its last child.
299

300
    Preconditions:
301
      - `node` must have a `last_child` (node).
302
      - `node` has col_offset property set.
303
    """
304
    last_child = _get_last_child(node)
20✔
305
    if not last_child:
20✔
306
        return node
20✔
307

308
    if last_child.end_lineno is not None:
20✔
309
        node.end_lineno = last_child.end_lineno
20✔
310
    if last_child.end_col_offset is not None:
20✔
311
        node.end_col_offset = last_child.end_col_offset
20✔
312
    return node
20✔
313

314

315
def _get_last_child(node):
20✔
316
    """Returns the last child node, or None.
317
    Some nodes' last_child() attribute not set, e.g. nodes.Arguments.
318
    """
319
    if node.last_child():
20✔
320
        return node.last_child()
20✔
321
    else:
322
        # Get the first child from the `get_children` generator.
323
        skip_to_last_child = None  # save reference to last child.
20✔
324
        for skip_to_last_child in node.get_children():
20✔
UNCOV
325
            pass  # skip to last
×
326
        return skip_to_last_child  # postcondition: node, or None.
20✔
327

328

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

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

337
    pred is a function that takes a string and index and returns a bool,
338
    e.g. _is_close_paren
339

340
    If only_consumables is True, the search halts when it reaches a non-consumable
341
    character that fails pred *on the first line*.
342
    TODO: really the behaviour should be the same for all lines searched for.
343
    """
344

345
    def set_endings_from_source(node):
20✔
346
        # Tuple nodes have an end_col_offset that includes the end paren,
347
        # but their col_offset does not include the start paren.
348
        # To address this, we override the Tuple node's end_col_offset.
349
        if isinstance(node, nodes.Tuple):
20✔
350
            set_from_last_child(node)
20✔
351

352
        # Initialize counters. Note: we need to offset lineno,
353
        # since it's 1-indexed.
354
        end_col_offset, lineno = node.end_col_offset, node.end_lineno - 1
20✔
355

356
        # First, search the remaining part of the current end line.
357
        for j in range(end_col_offset, len(source_code[lineno])):
20✔
358
            if source_code[lineno][j] == "#":
20✔
359
                break  # skip over comment lines
20✔
360
            if pred(source_code[lineno], j, node):
20✔
361
                node.end_col_offset = j + 1
20✔
362
                return node
20✔
363
            elif only_consumables and source_code[lineno][j] not in CONSUMABLES:
20✔
364
                return node
20✔
365

366
        # If that doesn't work, search remaining lines
367
        for i in range(lineno + 1, len(source_code)):
20✔
368
            # Search each character
369
            for j in range(len(source_code[i])):
20✔
370
                if source_code[i][j] == "#":
20✔
371
                    break  # skip over comment lines
20✔
372
                if pred(source_code[i], j, node):
20✔
373
                    node.end_col_offset, node.end_lineno = j + 1, i + 1
20✔
374
                    return node
20✔
375
                # only consume inert characters.
376
                elif source_code[i][j] not in CONSUMABLES:
20✔
377
                    return node
20✔
378
        return node
20✔
379

380
    return set_endings_from_source
20✔
381

382

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

387
    The basic technique is to do the following:
388
      1. Find the start locations for the node (already set).
389
      2. Starting at that point, iterate through characters in the source code
390
         in reverse until reaching the first index that satisfies pred.
391

392
    pred is a function that takes a string and index and returns a bool,
393
    e.g. _is_open_paren
394
    """
395

396
    def set_start_from_source(node):
20✔
397
        # Initialize counters. Note: fromlineno is 1-indexed.
398
        col_offset, lineno = node.col_offset, node.fromlineno - 1
20✔
399

400
        # First, search the remaining part of the current start line
401
        for j in range(min(len(source_code[lineno]) - 1, col_offset), -1, -1):
20✔
402
            if pred(source_code[lineno], j, node):
20✔
403
                node.col_offset = j
20✔
404
                return node
20✔
405

406
        # If that doesn't work, search remaining lines
UNCOV
407
        for i in range(lineno - 1, -1, -1):
×
408
            # Search each character, right-to-left
UNCOV
409
            for j in range(len(source_code[i]) - 1, -1, -1):
×
UNCOV
410
                if pred(source_code[i], j, node):
×
UNCOV
411
                    node.end_col_offset, node.end_lineno = j, i + 1
×
UNCOV
412
                    return node
×
413
                # only consume inert characters.
UNCOV
414
                elif source_code[i][j] not in CONSUMABLES:
×
UNCOV
415
                    return node
×
UNCOV
416
        return node
×
417

418
    return set_start_from_source
20✔
419

420

421
def add_parens(source_code):
20✔
422
    def h(node):
20✔
423
        _add_parens(source_code)(node)
20✔
424

425
    return h
20✔
426

427

428
def _add_parens(source_code):
20✔
429
    def h(node):
20✔
430
        # Initialize counters. Note: fromlineno is 1-indexed.
431
        prev = node.fromlineno, node.col_offset, node.end_lineno, node.end_col_offset
20✔
432
        while True:
20✔
433
            col_offset, lineno = node.col_offset, node.fromlineno - 1
20✔
434
            end_col_offset, end_lineno = node.end_col_offset, node.end_lineno - 1
20✔
435

436
            # First, search the remaining part of the current start line
437
            prev_char, new_lineno, new_coloffset = None, None, None
20✔
438
            for j in range(col_offset - 1, -1, -1):
20✔
439
                if source_code[lineno][j] in CONSUMABLES or source_code[lineno][j] == ",":
20✔
440
                    continue
20✔
441
                else:
442
                    prev_char, new_lineno, new_coloffset = source_code[lineno][j], lineno, j
20✔
443
                    break
20✔
444

445
            if prev_char is None:
20✔
446
                # Search remaining lines
447
                for i in range(lineno - 1, -1, -1):
20✔
448
                    # Search each character, right-to-left
449
                    for j in range(len(source_code[i]) - 1, -1, -1):
20✔
450
                        if source_code[i][j] in CONSUMABLES or source_code[i][j] == ",":
20✔
451
                            continue
20✔
452
                        else:
453
                            prev_char, new_lineno, new_coloffset = source_code[i][j], i, j
20✔
454

455
                            break
20✔
456
                    if prev_char is not None:
20✔
457
                        break
20✔
458

459
            if prev_char != "(":
20✔
460
                # No enclosing parentheses
461
                break
20✔
462

463
            # Now search for matching ')'
464
            next_char, new_end_lineno, new_end_coloffset = None, None, None
20✔
465
            for j in range(end_col_offset, len(source_code[end_lineno])):
20✔
466
                if source_code[end_lineno][j] == "#":
20✔
UNCOV
467
                    break  # skip over comment lines
×
468
                elif source_code[end_lineno][j] in CONSUMABLES:
20✔
469
                    continue
20✔
470
                else:
471
                    next_char, new_end_lineno, new_end_coloffset = (
20✔
472
                        source_code[end_lineno][j],
473
                        end_lineno,
474
                        j,
475
                    )
476
                    break
20✔
477

478
            if next_char is None:
20✔
479
                # Search remaining lines
480
                for i in range(end_lineno + 1, len(source_code)):
20✔
481
                    # Search each character
482
                    for j in range(len(source_code[i])):
20✔
483
                        if source_code[i][j] == "#":
20✔
UNCOV
484
                            break  # skip over comment lines
×
485
                        elif source_code[i][j] in CONSUMABLES:
20✔
486
                            continue
20✔
487
                        else:
488
                            next_char, new_end_lineno, new_end_coloffset = source_code[i][j], i, j
20✔
489
                            break
20✔
490
                    if next_char is not None:
20✔
491
                        break
20✔
492

493
            if next_char != ")":
20✔
494
                break
20✔
495

496
            # At this point, an enclosing pair of parentheses has been found
497
            prev = node.fromlineno, node.col_offset, node.end_lineno, node.end_col_offset
20✔
498
            node.fromlineno, node.col_offset, node.end_lineno, node.end_col_offset = (
20✔
499
                new_lineno + 1,
500
                new_coloffset,
501
                new_end_lineno + 1,
502
                new_end_coloffset + 1,
503
            )
504

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

509
        return node
20✔
510

511
    return h
20✔
512

513

514
# Make this module a pylint plugin
515
def register(linter):
20✔
516
    """Patch linter to apply message transform with source code."""
517
    old_get_ast = linter.get_ast
20✔
518

519
    def new_get_ast(filepath, modname, data):
20✔
520
        ast = old_get_ast(filepath, modname, data)
20✔
521
        if ast is not None:
20✔
522
            with open(filepath, encoding="utf-8") as f:
20✔
523
                source_code = f.readlines()
20✔
524
            ending_transformer = init_register_ending_setters(source_code)
20✔
525
            ending_transformer.visit(ast)
20✔
526
        return ast
20✔
527

528
    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