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

psf / black / 19849943215

02 Dec 2025 06:54AM UTC coverage: 95.808% (-0.02%) from 95.828%
19849943215

Pull #4872

github

web-flow
Merge 95d0bdbf9 into 7b265f166
Pull Request #4872: Fix crash when multiple `# fmt: skip` comments are used in multi-part if-clause (#4731)

5173 of 5451 branches covered (94.9%)

6 of 8 new or added lines in 1 file covered. (75.0%)

6 existing lines in 1 file now uncovered.

7885 of 8230 relevant lines covered (95.81%)

4.79 hits per line

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

97.09
/src/black/comments.py
1
import re
5✔
2
from collections.abc import Collection, Iterator
5✔
3
from dataclasses import dataclass
5✔
4
from functools import lru_cache
5✔
5
from typing import Final, Union
5✔
6

7
from black.mode import Mode, Preview
5✔
8
from black.nodes import (
5✔
9
    CLOSING_BRACKETS,
10
    STANDALONE_COMMENT,
11
    STATEMENT,
12
    WHITESPACE,
13
    container_of,
14
    first_leaf_of,
15
    is_type_comment_string,
16
    make_simple_prefix,
17
    preceding_leaf,
18
    syms,
19
)
20
from blib2to3.pgen2 import token
5✔
21
from blib2to3.pytree import Leaf, Node
5✔
22

23
# types
24
LN = Union[Leaf, Node]
5✔
25

26
FMT_OFF: Final = {"# fmt: off", "# fmt:off", "# yapf: disable"}
5✔
27
FMT_SKIP: Final = {"# fmt: skip", "# fmt:skip"}
5✔
28
FMT_ON: Final = {"# fmt: on", "# fmt:on", "# yapf: enable"}
5✔
29

30
# Compound statements we care about for fmt: skip handling
31
# (excludes except_clause and case_block which aren't standalone compound statements)
32
_COMPOUND_STATEMENTS: Final = STATEMENT - {syms.except_clause, syms.case_block}
5✔
33

34
COMMENT_EXCEPTIONS = " !:#'"
5✔
35
_COMMENT_PREFIX = "# "
5✔
36
_COMMENT_LIST_SEPARATOR = ";"
5✔
37

38

39
@dataclass
5✔
40
class ProtoComment:
5✔
41
    """Describes a piece of syntax that is a comment.
42

43
    It's not a :class:`blib2to3.pytree.Leaf` so that:
44

45
    * it can be cached (`Leaf` objects should not be reused more than once as
46
      they store their lineno, column, prefix, and parent information);
47
    * `newlines` and `consumed` fields are kept separate from the `value`. This
48
      simplifies handling of special marker comments like ``# fmt: off/on``.
49
    """
50

51
    type: int  # token.COMMENT or STANDALONE_COMMENT
5✔
52
    value: str  # content of the comment
5✔
53
    newlines: int  # how many newlines before the comment
5✔
54
    consumed: int  # how many characters of the original leaf's prefix did we consume
5✔
55
    form_feed: bool  # is there a form feed before the comment
5✔
56
    leading_whitespace: str  # leading whitespace before the comment, if any
5✔
57

58

59
def generate_comments(leaf: LN, mode: Mode) -> Iterator[Leaf]:
5✔
60
    """Clean the prefix of the `leaf` and generate comments from it, if any.
61

62
    Comments in lib2to3 are shoved into the whitespace prefix.  This happens
63
    in `pgen2/driver.py:Driver.parse_tokens()`.  This was a brilliant implementation
64
    move because it does away with modifying the grammar to include all the
65
    possible places in which comments can be placed.
66

67
    The sad consequence for us though is that comments don't "belong" anywhere.
68
    This is why this function generates simple parentless Leaf objects for
69
    comments.  We simply don't know what the correct parent should be.
70

71
    No matter though, we can live without this.  We really only need to
72
    differentiate between inline and standalone comments.  The latter don't
73
    share the line with any code.
74

75
    Inline comments are emitted as regular token.COMMENT leaves.  Standalone
76
    are emitted with a fake STANDALONE_COMMENT token identifier.
77
    """
78
    total_consumed = 0
5✔
79
    for pc in list_comments(
5✔
80
        leaf.prefix, is_endmarker=leaf.type == token.ENDMARKER, mode=mode
81
    ):
82
        total_consumed = pc.consumed
5✔
83
        prefix = make_simple_prefix(pc.newlines, pc.form_feed)
5✔
84
        yield Leaf(pc.type, pc.value, prefix=prefix)
5✔
85
    normalize_trailing_prefix(leaf, total_consumed)
5✔
86

87

88
@lru_cache(maxsize=4096)
5✔
89
def list_comments(prefix: str, *, is_endmarker: bool, mode: Mode) -> list[ProtoComment]:
5✔
90
    """Return a list of :class:`ProtoComment` objects parsed from the given `prefix`."""
91
    result: list[ProtoComment] = []
5✔
92
    if not prefix or "#" not in prefix:
5✔
93
        return result
5✔
94

95
    consumed = 0
5✔
96
    nlines = 0
5✔
97
    ignored_lines = 0
5✔
98
    form_feed = False
5✔
99
    for index, full_line in enumerate(re.split("\r?\n|\r", prefix)):
5✔
100
        consumed += len(full_line) + 1  # adding the length of the split '\n'
5✔
101
        match = re.match(r"^(\s*)(\S.*|)$", full_line)
5✔
102
        assert match
5✔
103
        whitespace, line = match.groups()
5✔
104
        if not line:
5✔
105
            nlines += 1
5✔
106
            if "\f" in full_line:
5✔
107
                form_feed = True
5✔
108
        if not line.startswith("#"):
5✔
109
            # Escaped newlines outside of a comment are not really newlines at
110
            # all. We treat a single-line comment following an escaped newline
111
            # as a simple trailing comment.
112
            if line.endswith("\\"):
5✔
113
                ignored_lines += 1
5✔
114
            continue
5✔
115

116
        if index == ignored_lines and not is_endmarker:
5✔
117
            comment_type = token.COMMENT  # simple trailing comment
5✔
118
        else:
119
            comment_type = STANDALONE_COMMENT
5✔
120
        comment = make_comment(line, mode=mode)
5✔
121
        result.append(
5✔
122
            ProtoComment(
123
                type=comment_type,
124
                value=comment,
125
                newlines=nlines,
126
                consumed=consumed,
127
                form_feed=form_feed,
128
                leading_whitespace=whitespace,
129
            )
130
        )
131
        form_feed = False
5✔
132
        nlines = 0
5✔
133
    return result
5✔
134

135

136
def normalize_trailing_prefix(leaf: LN, total_consumed: int) -> None:
5✔
137
    """Normalize the prefix that's left over after generating comments.
138

139
    Note: don't use backslashes for formatting or you'll lose your voting rights.
140
    """
141
    remainder = leaf.prefix[total_consumed:]
5✔
142
    if "\\" not in remainder:
5✔
143
        nl_count = remainder.count("\n")
5✔
144
        form_feed = "\f" in remainder and remainder.endswith("\n")
5✔
145
        leaf.prefix = make_simple_prefix(nl_count, form_feed)
5✔
146
        return
5✔
147

148
    leaf.prefix = ""
5✔
149

150

151
def make_comment(content: str, mode: Mode) -> str:
5✔
152
    """Return a consistently formatted comment from the given `content` string.
153

154
    All comments (except for "##", "#!", "#:", '#'") should have a single
155
    space between the hash sign and the content.
156

157
    If `content` didn't start with a hash sign, one is provided.
158

159
    Comments containing fmt directives are preserved exactly as-is to respect
160
    user intent (e.g., `#no space # fmt: skip` stays as-is).
161
    """
162
    content = content.rstrip()
5✔
163
    if not content:
5!
164
        return "#"
×
165

166
    # Preserve comments with fmt directives exactly as-is
167
    if content.startswith("#") and contains_fmt_directive(content):
5✔
168
        return content
5✔
169

170
    if content[0] == "#":
5!
171
        content = content[1:]
5✔
172
    if (
5✔
173
        content
174
        and content[0] == "\N{NO-BREAK SPACE}"
175
        and not is_type_comment_string("# " + content.lstrip(), mode=mode)
176
    ):
177
        content = " " + content[1:]  # Replace NBSP by a simple space
5✔
178
    if (
5✔
179
        Preview.standardize_type_comments in mode
180
        and content
181
        and "\N{NO-BREAK SPACE}" not in content
182
        and is_type_comment_string("#" + content, mode=mode)
183
    ):
184
        type_part, value_part = content.split(":", 1)
5✔
185
        content = type_part.strip() + ": " + value_part.strip()
5✔
186

187
    if content and content[0] not in COMMENT_EXCEPTIONS:
5✔
188
        content = " " + content
5✔
189
    return "#" + content
5✔
190

191

192
def normalize_fmt_off(
5✔
193
    node: Node, mode: Mode, lines: Collection[tuple[int, int]]
194
) -> None:
195
    """Convert content between `# fmt: off`/`# fmt: on` into standalone comments."""
196
    try_again = True
5✔
197
    while try_again:
5✔
198
        try_again = convert_one_fmt_off_pair(node, mode, lines)
5✔
199

200

201
def _should_process_fmt_comment(
5✔
202
    comment: ProtoComment, leaf: Leaf
203
) -> tuple[bool, bool, bool]:
204
    """Check if comment should be processed for fmt handling.
205

206
    Returns (should_process, is_fmt_off, is_fmt_skip).
207
    """
208
    is_fmt_off = contains_fmt_directive(comment.value, FMT_OFF)
5✔
209
    is_fmt_skip = contains_fmt_directive(comment.value, FMT_SKIP)
5✔
210

211
    if not is_fmt_off and not is_fmt_skip:
5✔
212
        return False, False, False
5✔
213

214
    # Invalid use when `# fmt: off` is applied before a closing bracket
215
    if is_fmt_off and leaf.type in CLOSING_BRACKETS:
5✔
216
        return False, False, False
5✔
217

218
    return True, is_fmt_off, is_fmt_skip
5✔
219

220

221
def _is_valid_standalone_fmt_comment(
5✔
222
    comment: ProtoComment, leaf: Leaf, is_fmt_off: bool, is_fmt_skip: bool
223
) -> bool:
224
    """Check if comment is a valid standalone fmt directive.
225

226
    We only want standalone comments. If there's no previous leaf or if
227
    the previous leaf is indentation, it's a standalone comment in disguise.
228
    """
229
    if comment.type == STANDALONE_COMMENT:
5✔
230
        return True
5✔
231

232
    prev = preceding_leaf(leaf)
5✔
233
    if not prev:
5✔
234
        return True
5✔
235

236
    # Treat STANDALONE_COMMENT nodes as whitespace for check
237
    if is_fmt_off and prev.type not in WHITESPACE and prev.type != STANDALONE_COMMENT:
5✔
238
        return False
5✔
239
    if is_fmt_skip and prev.type in WHITESPACE:
5✔
240
        return False
5✔
241

242
    return True
5✔
243

244

245
def _handle_comment_only_fmt_block(
5✔
246
    leaf: Leaf,
247
    comment: ProtoComment,
248
    previous_consumed: int,
249
    mode: Mode,
250
) -> bool:
251
    """Handle fmt:off/on blocks that contain only comments.
252

253
    Returns True if a block was converted, False otherwise.
254
    """
255
    all_comments = list_comments(leaf.prefix, is_endmarker=False, mode=mode)
5✔
256

257
    # Find the first fmt:off and its matching fmt:on
258
    fmt_off_idx = None
5✔
259
    fmt_on_idx = None
5✔
260
    for idx, c in enumerate(all_comments):
5!
261
        if fmt_off_idx is None and contains_fmt_directive(c.value, FMT_OFF):
5✔
262
            fmt_off_idx = idx
5✔
263
        if (
5✔
264
            fmt_off_idx is not None
265
            and idx > fmt_off_idx
266
            and contains_fmt_directive(c.value, FMT_ON)
267
        ):
268
            fmt_on_idx = idx
5✔
269
            break
5✔
270

271
    # Only proceed if we found both directives
272
    if fmt_on_idx is None or fmt_off_idx is None:
5!
273
        return False
×
274

275
    comment = all_comments[fmt_off_idx]
5✔
276
    fmt_on_comment = all_comments[fmt_on_idx]
5✔
277
    original_prefix = leaf.prefix
5✔
278

279
    # Build the hidden value
280
    start_pos = comment.consumed
5✔
281
    end_pos = fmt_on_comment.consumed
5✔
282
    content_between_and_fmt_on = original_prefix[start_pos:end_pos]
5✔
283
    hidden_value = comment.value + "\n" + content_between_and_fmt_on
5✔
284

285
    if hidden_value.endswith("\n"):
5!
286
        hidden_value = hidden_value[:-1]
5✔
287

288
    # Build the standalone comment prefix - preserve all content before fmt:off
289
    # including any comments that precede it
290
    if fmt_off_idx == 0:
5✔
291
        # No comments before fmt:off, use previous_consumed
292
        pre_fmt_off_consumed = previous_consumed
5✔
293
    else:
294
        # Use the consumed position of the last comment before fmt:off
295
        # This preserves all comments and content before the fmt:off directive
296
        pre_fmt_off_consumed = all_comments[fmt_off_idx - 1].consumed
5✔
297

298
    standalone_comment_prefix = (
5✔
299
        original_prefix[:pre_fmt_off_consumed] + "\n" * comment.newlines
300
    )
301

302
    fmt_off_prefix = original_prefix.split(comment.value)[0]
5✔
303
    if "\n" in fmt_off_prefix:
5✔
304
        fmt_off_prefix = fmt_off_prefix.split("\n")[-1]
5✔
305
    standalone_comment_prefix += fmt_off_prefix
5✔
306

307
    # Update leaf prefix
308
    leaf.prefix = original_prefix[fmt_on_comment.consumed :]
5✔
309

310
    # Insert the STANDALONE_COMMENT
311
    parent = leaf.parent
5✔
312
    assert parent is not None, "INTERNAL ERROR: fmt: on/off handling (prefix only)"
5✔
313

314
    leaf_idx = None
5✔
315
    for idx, child in enumerate(parent.children):
5!
316
        if child is leaf:
5✔
317
            leaf_idx = idx
5✔
318
            break
5✔
319

320
    assert leaf_idx is not None, "INTERNAL ERROR: fmt: on/off handling (leaf index)"
5✔
321

322
    parent.insert_child(
5✔
323
        leaf_idx,
324
        Leaf(
325
            STANDALONE_COMMENT,
326
            hidden_value,
327
            prefix=standalone_comment_prefix,
328
            fmt_pass_converted_first_leaf=None,
329
        ),
330
    )
331
    return True
5✔
332

333

334
def convert_one_fmt_off_pair(
5✔
335
    node: Node, mode: Mode, lines: Collection[tuple[int, int]]
336
) -> bool:
337
    """Convert content of a single `# fmt: off`/`# fmt: on` into a standalone comment.
338

339
    Returns True if a pair was converted.
340
    """
341
    for leaf in node.leaves():
5✔
342
        # Skip STANDALONE_COMMENT nodes that were created by fmt:off/on processing
343
        # to avoid reprocessing them in subsequent iterations
344
        if (
5✔
345
            leaf.type == STANDALONE_COMMENT
346
            and hasattr(leaf, "fmt_pass_converted_first_leaf")
347
            and leaf.fmt_pass_converted_first_leaf is None
348
        ):
349
            continue
5✔
350

351
        previous_consumed = 0
5✔
352
        for comment in list_comments(leaf.prefix, is_endmarker=False, mode=mode):
5✔
353
            should_process, is_fmt_off, is_fmt_skip = _should_process_fmt_comment(
5✔
354
                comment, leaf
355
            )
356
            if not should_process:
5✔
357
                previous_consumed = comment.consumed
5✔
358
                continue
5✔
359

360
            if not _is_valid_standalone_fmt_comment(
5✔
361
                comment, leaf, is_fmt_off, is_fmt_skip
362
            ):
363
                previous_consumed = comment.consumed
5✔
364
                continue
5✔
365

366
            ignored_nodes = list(generate_ignored_nodes(leaf, comment, mode))
5✔
367

368
            # Handle comment-only blocks
369
            if not ignored_nodes and is_fmt_off:
5✔
370
                if _handle_comment_only_fmt_block(
5!
371
                    leaf, comment, previous_consumed, mode
372
                ):
373
                    return True
5✔
UNCOV
374
                continue
×
375

376
            # Need actual nodes to process
377
            if not ignored_nodes:
5✔
378
                continue
5✔
379

380
            # Handle regular fmt blocks
381

382
            _handle_regular_fmt_block(
5✔
383
                ignored_nodes,
384
                comment,
385
                previous_consumed,
386
                is_fmt_skip,
387
                lines,
388
                leaf,
389
            )
390
            return True
5✔
391

392
    return False
5✔
393

394

395
def _handle_regular_fmt_block(
5✔
396
    ignored_nodes: list[LN],
397
    comment: ProtoComment,
398
    previous_consumed: int,
399
    is_fmt_skip: bool,
400
    lines: Collection[tuple[int, int]],
401
    leaf: Leaf,
402
) -> None:
403
    """Handle fmt blocks with actual AST nodes."""
404
    first = ignored_nodes[0]  # Can be a container node with the `leaf`.
5✔
405
    parent = first.parent
5✔
406
    prefix = first.prefix
5✔
407

408
    if contains_fmt_directive(comment.value, FMT_OFF):
5✔
409
        first.prefix = prefix[comment.consumed :]
5✔
410
    if is_fmt_skip:
5✔
411
        first.prefix = ""
5✔
412
        standalone_comment_prefix = prefix
5✔
413
    else:
414
        standalone_comment_prefix = prefix[:previous_consumed] + "\n" * comment.newlines
5✔
415

416
    # Ensure STANDALONE_COMMENT nodes have trailing newlines when stringified
417
    # This prevents multiple fmt: skip comments from being concatenated on one line
418
    for node in ignored_nodes:
5✔
419
        if isinstance(node, Leaf) and node.type == STANDALONE_COMMENT:
5!
NEW
420
            if not node.value.endswith("\n"):
×
NEW
421
                node.value += "\n"
×
422
        elif isinstance(node, Node):
5✔
423
            for leaf in node.leaves():
5✔
424
                if leaf.type == STANDALONE_COMMENT and not leaf.value.endswith("\n"):
5✔
425
                    leaf.value += "\n"
5✔
426

427
    hidden_value = "".join(str(n) for n in ignored_nodes)
5✔
428
    comment_lineno = leaf.lineno - comment.newlines
5✔
429

430
    if contains_fmt_directive(comment.value, FMT_OFF):
5✔
431
        fmt_off_prefix = ""
5✔
432
        if len(lines) > 0 and not any(
5✔
433
            line[0] <= comment_lineno <= line[1] for line in lines
434
        ):
435
            # keeping indentation of comment by preserving original whitespaces.
436
            fmt_off_prefix = prefix.split(comment.value)[0]
5✔
437
            if "\n" in fmt_off_prefix:
5!
438
                fmt_off_prefix = fmt_off_prefix.split("\n")[-1]
5✔
439
        standalone_comment_prefix += fmt_off_prefix
5✔
440
        hidden_value = comment.value + "\n" + hidden_value
5✔
441

442
    if is_fmt_skip:
5✔
443
        hidden_value += comment.leading_whitespace + comment.value
5✔
444

445
    if hidden_value.endswith("\n"):
5✔
446
        # That happens when one of the `ignored_nodes` ended with a NEWLINE
447
        # leaf (possibly followed by a DEDENT).
448
        hidden_value = hidden_value[:-1]
5✔
449

450
    first_idx: int | None = None
5✔
451
    for ignored in ignored_nodes:
5✔
452
        index = ignored.remove()
5✔
453
        if first_idx is None:
5✔
454
            first_idx = index
5✔
455

456
    assert parent is not None, "INTERNAL ERROR: fmt: on/off handling (1)"
5✔
457
    assert first_idx is not None, "INTERNAL ERROR: fmt: on/off handling (2)"
5✔
458

459
    parent.insert_child(
5✔
460
        first_idx,
461
        Leaf(
462
            STANDALONE_COMMENT,
463
            hidden_value,
464
            prefix=standalone_comment_prefix,
465
            fmt_pass_converted_first_leaf=first_leaf_of(first),
466
        ),
467
    )
468

469

470
def generate_ignored_nodes(
5✔
471
    leaf: Leaf, comment: ProtoComment, mode: Mode
472
) -> Iterator[LN]:
473
    """Starting from the container of `leaf`, generate all leaves until `# fmt: on`.
474

475
    If comment is skip, returns leaf only.
476
    Stops at the end of the block.
477
    """
478
    if contains_fmt_directive(comment.value, FMT_SKIP):
5✔
479
        yield from _generate_ignored_nodes_from_fmt_skip(leaf, comment, mode)
5✔
480
        return
5✔
481
    container: LN | None = container_of(leaf)
5✔
482
    while container is not None and container.type != token.ENDMARKER:
5✔
483
        if is_fmt_on(container, mode=mode):
5✔
484
            return
5✔
485

486
        # fix for fmt: on in children
487
        if children_contains_fmt_on(container, mode=mode):
5✔
488
            for index, child in enumerate(container.children):
5!
489
                if isinstance(child, Leaf) and is_fmt_on(child, mode=mode):
5✔
490
                    if child.type in CLOSING_BRACKETS:
5✔
491
                        # This means `# fmt: on` is placed at a different bracket level
492
                        # than `# fmt: off`. This is an invalid use, but as a courtesy,
493
                        # we include this closing bracket in the ignored nodes.
494
                        # The alternative is to fail the formatting.
495
                        yield child
5✔
496
                    return
5✔
497
                if (
5✔
498
                    child.type == token.INDENT
499
                    and index < len(container.children) - 1
500
                    and children_contains_fmt_on(
501
                        container.children[index + 1], mode=mode
502
                    )
503
                ):
504
                    # This means `# fmt: on` is placed right after an indentation
505
                    # level, and we shouldn't swallow the previous INDENT token.
506
                    return
5✔
507
                if children_contains_fmt_on(child, mode=mode):
5✔
508
                    return
5✔
509
                yield child
5✔
510
        else:
511
            if container.type == token.DEDENT and container.next_sibling is None:
5✔
512
                # This can happen when there is no matching `# fmt: on` comment at the
513
                # same level as `# fmt: on`. We need to keep this DEDENT.
514
                return
5✔
515
            yield container
5✔
516
            container = container.next_sibling
5✔
517

518

519
def _find_compound_statement_context(parent: Node) -> Node | None:
5✔
520
    """Return the body node of a compound statement if we should respect fmt: skip.
521

522
    This handles one-line compound statements like:
523
        if condition: body  # fmt: skip
524

525
    When Black expands such statements, they temporarily look like:
526
        if condition:
527
            body  # fmt: skip
528

529
    In both cases, we want to return the body node (either the simple_stmt directly
530
    or the suite containing it).
531
    """
532
    if parent.type != syms.simple_stmt:
5✔
533
        return None
5✔
534

535
    if not isinstance(parent.parent, Node):
5!
UNCOV
536
        return None
×
537

538
    # Case 1: Expanded form after Black's initial formatting pass.
539
    # The one-liner has been split across multiple lines:
540
    #     if True:
541
    #         print("a"); print("b")  # fmt: skip
542
    # Structure: compound_stmt -> suite -> simple_stmt
543
    if (
5✔
544
        parent.parent.type == syms.suite
545
        and isinstance(parent.parent.parent, Node)
546
        and parent.parent.parent.type in _COMPOUND_STATEMENTS
547
    ):
548
        return parent.parent
5✔
549

550
    # Case 2: Original one-line form from the input source.
551
    # The statement is still on a single line:
552
    #     if True: print("a"); print("b")  # fmt: skip
553
    # Structure: compound_stmt -> simple_stmt
554
    if parent.parent.type in _COMPOUND_STATEMENTS:
5✔
555
        return parent
5✔
556

557
    return None
5✔
558

559

560
def _should_keep_compound_statement_inline(
5✔
561
    body_node: Node, simple_stmt_parent: Node
562
) -> bool:
563
    """Check if a compound statement should be kept on one line.
564

565
    Returns True only for compound statements with semicolon-separated bodies,
566
    like: if True: print("a"); print("b")  # fmt: skip
567
    """
568
    # Check if there are semicolons in the body
569
    for leaf in body_node.leaves():
5✔
570
        if leaf.type == token.SEMI:
5✔
571
            # Verify it's a single-line body (one simple_stmt)
572
            if body_node.type == syms.suite:
5!
573
                # After formatting: check suite has one simple_stmt child
UNCOV
574
                simple_stmts = [
×
575
                    child
576
                    for child in body_node.children
577
                    if child.type == syms.simple_stmt
578
                ]
UNCOV
579
                return len(simple_stmts) == 1 and simple_stmts[0] is simple_stmt_parent
×
580
            else:
581
                # Original form: body_node IS the simple_stmt
582
                return body_node is simple_stmt_parent
5✔
583
    return False
5✔
584

585

586
def _get_compound_statement_header(
5✔
587
    body_node: Node, simple_stmt_parent: Node
588
) -> list[LN]:
589
    """Get header nodes for a compound statement that should be preserved inline."""
590
    if not _should_keep_compound_statement_inline(body_node, simple_stmt_parent):
5✔
591
        return []
5✔
592

593
    # Get the compound statement (parent of body)
594
    compound_stmt = body_node.parent
5✔
595
    if compound_stmt is None or compound_stmt.type not in _COMPOUND_STATEMENTS:
5!
UNCOV
596
        return []
×
597

598
    # Collect all header leaves before the body
599
    header_leaves: list[LN] = []
5✔
600
    for child in compound_stmt.children:
5!
601
        if child is body_node:
5✔
602
            break
5✔
603
        if isinstance(child, Leaf):
5✔
604
            if child.type not in (token.NEWLINE, token.INDENT):
5!
605
                header_leaves.append(child)
5✔
606
        else:
607
            header_leaves.extend(child.leaves())
5✔
608
    return header_leaves
5✔
609

610

611
def _generate_ignored_nodes_from_fmt_skip(
5✔
612
    leaf: Leaf, comment: ProtoComment, mode: Mode
613
) -> Iterator[LN]:
614
    """Generate all leaves that should be ignored by the `# fmt: skip` from `leaf`."""
615
    prev_sibling = leaf.prev_sibling
5✔
616
    parent = leaf.parent
5✔
617
    ignored_nodes: list[LN] = []
5✔
618
    # Need to properly format the leaf prefix to compare it to comment.value,
619
    # which is also formatted
620
    comments = list_comments(leaf.prefix, is_endmarker=False, mode=mode)
5✔
621
    if not comments or comment.value != comments[0].value:
5!
UNCOV
622
        return
×
623

624
    if Preview.fix_fmt_skip_in_one_liners in mode and not prev_sibling and parent:
5✔
625
        prev_sibling = parent.prev_sibling
5✔
626

627
    if prev_sibling is not None:
5✔
628
        leaf.prefix = leaf.prefix[comment.consumed :]
5✔
629

630
        if Preview.fix_fmt_skip_in_one_liners not in mode:
5✔
631
            siblings = [prev_sibling]
5✔
632
            while (
5✔
633
                "\n" not in prev_sibling.prefix
634
                and prev_sibling.prev_sibling is not None
635
            ):
636
                prev_sibling = prev_sibling.prev_sibling
5✔
637
                siblings.insert(0, prev_sibling)
5✔
638
            yield from siblings
5✔
639
            return
5✔
640

641
        # Generates the nodes to be ignored by `fmt: skip`.
642

643
        # Nodes to ignore are the ones on the same line as the
644
        # `# fmt: skip` comment, excluding the `# fmt: skip`
645
        # node itself.
646

647
        # Traversal process (starting at the `# fmt: skip` node):
648
        # 1. Move to the `prev_sibling` of the current node.
649
        # 2. If `prev_sibling` has children, go to its rightmost leaf.
650
        # 3. If there's no `prev_sibling`, move up to the parent
651
        # node and repeat.
652
        # 4. Continue until:
653
        #    a. You encounter an `INDENT` or `NEWLINE` node (indicates
654
        #       start of the line).
655
        #    b. You reach the root node.
656

657
        # Include all visited LEAVES in the ignored list, except INDENT
658
        # or NEWLINE leaves.
659

660
        current_node = prev_sibling
5✔
661
        ignored_nodes = [current_node]
5✔
662
        if current_node.prev_sibling is None and current_node.parent is not None:
5✔
663
            current_node = current_node.parent
5✔
664
        while "\n" not in current_node.prefix and current_node.prev_sibling is not None:
5✔
665
            leaf_nodes = list(current_node.prev_sibling.leaves())
5✔
666
            current_node = leaf_nodes[-1] if leaf_nodes else current_node
5✔
667

668
            if (
5✔
669
                current_node.type in CLOSING_BRACKETS
670
                and current_node.parent
671
                and current_node.parent.type == syms.atom
672
            ):
673
                current_node = current_node.parent
5✔
674

675
            if current_node.type in (token.NEWLINE, token.INDENT):
5✔
676
                current_node.prefix = ""
5✔
677
                break
5✔
678

679
            if current_node.type == token.DEDENT:
5✔
680
                break
5✔
681

682
            # Special case for with expressions
683
            # Without this, we can stuck inside the asexpr_test's children's children
684
            if (
5✔
685
                current_node.parent
686
                and current_node.parent.type == syms.asexpr_test
687
                and current_node.parent.parent
688
                and current_node.parent.parent.type == syms.with_stmt
689
            ):
690
                current_node = current_node.parent
5✔
691

692
            ignored_nodes.insert(0, current_node)
5✔
693

694
            if current_node.prev_sibling is None and current_node.parent is not None:
5✔
695
                current_node = current_node.parent
5✔
696

697
        # Special handling for compound statements with semicolon-separated bodies
698
        if Preview.fix_fmt_skip_in_one_liners in mode and isinstance(parent, Node):
5!
699
            body_node = _find_compound_statement_context(parent)
5✔
700
            if body_node is not None:
5✔
701
                header_nodes = _get_compound_statement_header(body_node, parent)
5✔
702
                if header_nodes:
5✔
703
                    ignored_nodes = header_nodes + ignored_nodes
5✔
704

705
        yield from ignored_nodes
5✔
706
    elif (
5✔
707
        parent is not None and parent.type == syms.suite and leaf.type == token.NEWLINE
708
    ):
709
        # The `# fmt: skip` is on the colon line of the if/while/def/class/...
710
        # statements. The ignored nodes should be previous siblings of the
711
        # parent suite node.
712
        leaf.prefix = ""
5✔
713
        parent_sibling = parent.prev_sibling
5✔
714
        while parent_sibling is not None and parent_sibling.type != syms.suite:
5✔
715
            ignored_nodes.insert(0, parent_sibling)
5✔
716
            parent_sibling = parent_sibling.prev_sibling
5✔
717
        # Special case for `async_stmt` where the ASYNC token is on the
718
        # grandparent node.
719
        grandparent = parent.parent
5✔
720
        if (
5✔
721
            grandparent is not None
722
            and grandparent.prev_sibling is not None
723
            and grandparent.prev_sibling.type == token.ASYNC
724
        ):
725
            ignored_nodes.insert(0, grandparent.prev_sibling)
5✔
726
        yield from iter(ignored_nodes)
5✔
727

728

729
def is_fmt_on(container: LN, mode: Mode) -> bool:
5✔
730
    """Determine whether formatting is switched on within a container.
731
    Determined by whether the last `# fmt:` comment is `on` or `off`.
732
    """
733
    fmt_on = False
5✔
734
    for comment in list_comments(container.prefix, is_endmarker=False, mode=mode):
5✔
735
        if contains_fmt_directive(comment.value, FMT_ON):
5✔
736
            fmt_on = True
5✔
737
        elif contains_fmt_directive(comment.value, FMT_OFF):
5✔
738
            fmt_on = False
5✔
739
    return fmt_on
5✔
740

741

742
def children_contains_fmt_on(container: LN, mode: Mode) -> bool:
5✔
743
    """Determine if children have formatting switched on."""
744
    for child in container.children:
5✔
745
        leaf = first_leaf_of(child)
5✔
746
        if leaf is not None and is_fmt_on(leaf, mode=mode):
5✔
747
            return True
5✔
748

749
    return False
5✔
750

751

752
def contains_pragma_comment(comment_list: list[Leaf]) -> bool:
5✔
753
    """
754
    Returns:
755
        True iff one of the comments in @comment_list is a pragma used by one
756
        of the more common static analysis tools for python (e.g. mypy, flake8,
757
        pylint).
758
    """
759
    for comment in comment_list:
5✔
760
        if comment.value.startswith(("# type:", "# noqa", "# pylint:")):
5✔
761
            return True
5✔
762

763
    return False
5✔
764

765

766
def contains_fmt_directive(
5✔
767
    comment_line: str, directives: set[str] = FMT_OFF | FMT_ON | FMT_SKIP
768
) -> bool:
769
    """
770
    Checks if the given comment contains format directives, alone or paired with
771
    other comments.
772

773
    Defaults to checking all directives (skip, off, on, yapf), but can be
774
    narrowed to specific ones.
775

776
    Matching styles:
777
      # foobar                    <-- single comment
778
      # foobar # foobar # foobar  <-- multiple comments
779
      # foobar; foobar            <-- list of comments (; separated)
780
    """
781
    semantic_comment_blocks = [
5✔
782
        comment_line,
783
        *[
784
            _COMMENT_PREFIX + comment.strip()
785
            for comment in comment_line.split(_COMMENT_PREFIX)[1:]
786
        ],
787
        *[
788
            _COMMENT_PREFIX + comment.strip()
789
            for comment in comment_line.strip(_COMMENT_PREFIX).split(
790
                _COMMENT_LIST_SEPARATOR
791
            )
792
        ],
793
    ]
794

795
    return any(comment in directives for comment in semantic_comment_blocks)
5✔
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