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

psf / black / 26073227988

19 May 2026 02:51AM UTC coverage: 95.748% (-0.005%) from 95.753%
26073227988

Pull #5129

github

web-flow
Merge 2848abf20 into 0fff1dbae
Pull Request #5129: Respect NO_COLOR by disabling ANSI output

5612 of 5932 branches covered (94.61%)

31 of 31 new or added lines in 3 files covered. (100.0%)

67 existing lines in 3 files now uncovered.

8421 of 8795 relevant lines covered (95.75%)

5.73 hits per line

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

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

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

24
# types
25
LN = Union[Leaf, Node]
6✔
26

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

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

35
COMMENT_EXCEPTIONS = " !:#'"
6✔
36
_COMMENT_PREFIX = "# "
6✔
37
_COMMENT_LIST_SEPARATOR = ";"
6✔
38

39

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

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

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

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

59

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

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

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

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

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

88

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

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

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

136

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

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

149
    leaf.prefix = ""
6✔
150

151

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

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

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

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

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

171
    if content[0] == "#":
6!
172
        content = content[1:]
6✔
173
    if (
6✔
174
        content
175
        and content[0] == "\N{NO-BREAK SPACE}"
176
        and not is_type_comment_string("# " + content.lstrip(), mode=mode)
177
    ):
178
        content = " " + content[1:]  # Replace NBSP by a simple space
6✔
179
    if (
6✔
180
        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)
6✔
185
        content = type_part.strip() + ": " + value_part.strip()
6✔
186

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

191

192
def normalize_fmt_off(
6✔
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
6✔
197
    while try_again:
6✔
198
        try_again = convert_one_fmt_off_pair(node, mode, lines)
6✔
199

200

201
def _should_process_fmt_comment(
6✔
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)
6✔
209
    is_fmt_skip = contains_fmt_directive(comment.value, FMT_SKIP)
6✔
210

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

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

218
    return True, is_fmt_off, is_fmt_skip
6✔
219

220

221
def _is_valid_standalone_fmt_comment(
6✔
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:
6✔
230
        return True
6✔
231

232
    prev = preceding_leaf(leaf)
6✔
233
    if not prev:
6✔
234
        return True
6✔
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:
6✔
238
        return False
6✔
239
    if is_fmt_skip and prev.type in WHITESPACE:
6✔
240
        return False
6✔
241

242
    return True
6✔
243

244

245
def _handle_comment_only_fmt_block(
6✔
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)
6✔
256

257
    # Find the first fmt:off and its matching fmt:on
258
    fmt_off_idx = None
6✔
259
    fmt_on_idx = None
6✔
260
    for idx, c in enumerate(all_comments):
6!
261
        if fmt_off_idx is None and contains_fmt_directive(c.value, FMT_OFF):
6✔
262
            fmt_off_idx = idx
6✔
263
        if (
6✔
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
6✔
269
            break
6✔
270

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

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

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

285
    if hidden_value.endswith("\n"):
6!
286
        hidden_value = hidden_value[:-1]
6✔
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:
6✔
291
        # No comments before fmt:off, use previous_consumed
292
        pre_fmt_off_consumed = previous_consumed
6✔
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
6✔
297

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

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

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

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

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

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

322
    parent.insert_child(
6✔
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
6✔
332

333

334
def convert_one_fmt_off_pair(
6✔
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():
6✔
342
        # Skip STANDALONE_COMMENT nodes that were created by fmt:off/on/skip processing
343
        # to avoid reprocessing them in subsequent iterations
344
        if leaf.type == STANDALONE_COMMENT and hasattr(
6✔
345
            leaf, "fmt_pass_converted_first_leaf"
346
        ):
347
            continue
6✔
348

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

358
            if not _is_valid_standalone_fmt_comment(
6✔
359
                comment, leaf, is_fmt_off, is_fmt_skip
360
            ):
361
                previous_consumed = comment.consumed
6✔
362
                continue
6✔
363

364
            ignored_nodes = list(generate_ignored_nodes(leaf, comment, mode))
6✔
365

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

374
            # Need actual nodes to process
375
            if not ignored_nodes:
6✔
376
                continue
6✔
377

378
            # Handle regular fmt blocks
379

380
            _handle_regular_fmt_block(
6✔
381
                ignored_nodes,
382
                comment,
383
                previous_consumed,
384
                is_fmt_skip,
385
                lines,
386
                leaf,
387
            )
388
            return True
6✔
389

390
    return False
6✔
391

392

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

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

414
    # Ensure STANDALONE_COMMENT nodes have trailing newlines when stringified
415
    # This prevents multiple fmt: skip comments from being concatenated on one line
416
    parts = []
6✔
417
    for node in ignored_nodes:
6✔
418
        if isinstance(node, Leaf) and node.type == STANDALONE_COMMENT:
6!
419
            # Add newline after STANDALONE_COMMENT Leaf
420
            node_str = str(node)
×
421
            if not node_str.endswith("\n"):
×
422
                node_str += "\n"
×
423
            parts.append(node_str)
×
424
        elif isinstance(node, Node):
6✔
425
            # For nodes that might contain STANDALONE_COMMENT leaves,
426
            # we need custom stringify
427
            has_standalone = any(
6✔
428
                leaf.type == STANDALONE_COMMENT for leaf in node.leaves()
429
            )
430
            if has_standalone:
6✔
431
                # Stringify node with STANDALONE_COMMENT leaves having trailing newlines
432
                def stringify_node(n: LN) -> str:
6✔
433
                    if isinstance(n, Leaf):
6✔
434
                        if n.type == STANDALONE_COMMENT:
6✔
435
                            result = n.prefix + n.value
6✔
436
                            if not result.endswith("\n"):
6!
437
                                result += "\n"
6✔
438
                            return result
6✔
439
                        return str(n)
6✔
440
                    else:
441
                        # For nested nodes, recursively process children
442
                        return "".join(stringify_node(child) for child in n.children)
6✔
443

444
                parts.append(stringify_node(node))
6✔
445
            else:
446
                parts.append(str(node))
6✔
447
        else:
448
            parts.append(str(node))
6✔
449

450
    hidden_value = "".join(parts)
6✔
451
    comment_lineno = leaf.lineno - comment.newlines
6✔
452
    leaf_is_ignored = any(
6✔
453
        ignored is leaf
454
        or (
455
            isinstance(ignored, Node)
456
            and any(child is leaf for child in ignored.leaves())
457
        )
458
        for ignored in ignored_nodes
459
    )
460

461
    if contains_fmt_directive(comment.value, FMT_OFF):
6✔
462
        fmt_off_prefix = ""
6✔
463
        if len(lines) > 0 and not any(
6✔
464
            line[0] <= comment_lineno <= line[1] for line in lines
465
        ):
466
            # keeping indentation of comment by preserving original whitespaces.
467
            fmt_off_prefix = prefix.split(comment.value)[0]
6✔
468
            if "\n" in fmt_off_prefix:
6✔
469
                fmt_off_prefix = fmt_off_prefix.split("\n")[-1]
6✔
470
        standalone_comment_prefix += fmt_off_prefix
6✔
471
        hidden_value = comment.value + "\n" + hidden_value
6✔
472

473
    if is_fmt_skip and not leaf_is_ignored:
6✔
474
        hidden_value += comment.leading_whitespace + comment.value
6✔
475

476
    if hidden_value.endswith("\n"):
6✔
477
        # That happens when one of the `ignored_nodes` ended with a NEWLINE
478
        # leaf (possibly followed by a DEDENT).
479
        hidden_value = hidden_value[:-1]
6✔
480

481
    first_idx: int | None = None
6✔
482
    for ignored in ignored_nodes:
6✔
483
        index = ignored.remove()
6✔
484
        if first_idx is None:
6✔
485
            first_idx = index
6✔
486

487
    assert parent is not None, "INTERNAL ERROR: fmt: on/off handling (1)"
6✔
488
    assert first_idx is not None, "INTERNAL ERROR: fmt: on/off handling (2)"
6✔
489

490
    parent.insert_child(
6✔
491
        first_idx,
492
        Leaf(
493
            STANDALONE_COMMENT,
494
            hidden_value,
495
            prefix=standalone_comment_prefix,
496
            fmt_pass_converted_first_leaf=first_leaf_of(first),
497
        ),
498
    )
499

500

501
def generate_ignored_nodes(
6✔
502
    leaf: Leaf, comment: ProtoComment, mode: Mode
503
) -> Iterator[LN]:
504
    """Starting from the container of `leaf`, generate all leaves until `# fmt: on`.
505

506
    If comment is skip, returns leaf only.
507
    Stops at the end of the block.
508
    """
509
    if contains_fmt_directive(comment.value, FMT_SKIP):
6✔
510
        yield from _generate_ignored_nodes_from_fmt_skip(leaf, comment, mode)
6✔
511
        return
6✔
512
    container: LN | None = container_of(leaf)
6✔
513
    while container is not None and container.type != token.ENDMARKER:
6✔
514
        if is_fmt_on(container, mode=mode):
6✔
515
            return
6✔
516

517
        # fix for fmt: on in children
518
        if children_contains_fmt_on(container, mode=mode):
6✔
519
            for index, child in enumerate(container.children):
6!
520
                if isinstance(child, Leaf) and is_fmt_on(child, mode=mode):
6✔
521
                    if child.type in CLOSING_BRACKETS:
6✔
522
                        # This means `# fmt: on` is placed at a different bracket level
523
                        # than `# fmt: off`. This is an invalid use, but as a courtesy,
524
                        # we include this closing bracket in the ignored nodes.
525
                        # The alternative is to fail the formatting.
526
                        yield child
6✔
527
                    return
6✔
528
                if (
6✔
529
                    child.type == token.INDENT
530
                    and index < len(container.children) - 1
531
                    and children_contains_fmt_on(
532
                        container.children[index + 1], mode=mode
533
                    )
534
                ):
535
                    # This means `# fmt: on` is placed right after an indentation
536
                    # level, and we shouldn't swallow the previous INDENT token.
537
                    return
6✔
538
                if children_contains_fmt_on(child, mode=mode):
6✔
539
                    return
6✔
540
                yield child
6✔
541
        else:
542
            if container.type == token.DEDENT and container.next_sibling is None:
6✔
543
                # This can happen when there is no matching `# fmt: on` comment at the
544
                # same level as `# fmt: on`. We need to keep this DEDENT.
545
                return
6✔
546
            yield container
6✔
547
            container = container.next_sibling
6✔
548

549

550
def _find_compound_statement_context(parent: Node) -> Node | None:
6✔
551
    """Return the body node of a compound statement if we should respect fmt: skip.
552

553
    This handles one-line compound statements like:
554
        if condition: body  # fmt: skip
555

556
    When Black expands such statements, they temporarily look like:
557
        if condition:
558
            body  # fmt: skip
559

560
    In both cases, we want to return the body node (either the simple_stmt directly
561
    or the suite containing it).
562
    """
563
    if parent.type != syms.simple_stmt:
6✔
564
        return None
6✔
565

566
    if not isinstance(parent.parent, Node):
6!
567
        return None
×
568

569
    # Case 1: Expanded form after Black's initial formatting pass.
570
    # The one-liner has been split across multiple lines:
571
    #     if True:
572
    #         print("a"); print("b")  # fmt: skip
573
    # Structure: compound_stmt -> suite -> simple_stmt
574
    if (
6✔
575
        parent.parent.type == syms.suite
576
        and isinstance(parent.parent.parent, Node)
577
        and parent.parent.parent.type in _COMPOUND_STATEMENTS
578
    ):
579
        return parent.parent
6✔
580

581
    # Case 2: Original one-line form from the input source.
582
    # The statement is still on a single line:
583
    #     if True: print("a"); print("b")  # fmt: skip
584
    # Structure: compound_stmt -> simple_stmt
585
    if parent.parent.type in _COMPOUND_STATEMENTS:
6✔
586
        return parent
6✔
587

588
    return None
6✔
589

590

591
def _should_keep_compound_statement_inline(
6✔
592
    body_node: Node, simple_stmt_parent: Node
593
) -> bool:
594
    """Check if a compound statement should be kept on one line.
595

596
    Returns True only for compound statements with semicolon-separated bodies,
597
    like: if True: print("a"); print("b")  # fmt: skip
598
    """
599
    # Check if there are semicolons in the body
600
    for leaf in body_node.leaves():
6✔
601
        if leaf.type == token.SEMI:
6✔
602
            # Verify it's a single-line body (one simple_stmt)
603
            if body_node.type == syms.suite:
6!
604
                # After formatting: check suite has one simple_stmt child
605
                simple_stmts = [
×
606
                    child
607
                    for child in body_node.children
608
                    if child.type == syms.simple_stmt
609
                ]
610
                return len(simple_stmts) == 1 and simple_stmts[0] is simple_stmt_parent
×
611
            else:
612
                # Original form: body_node IS the simple_stmt
613
                return body_node is simple_stmt_parent
6✔
614
    return False
6✔
615

616

617
def _get_compound_statement_header(
6✔
618
    body_node: Node, simple_stmt_parent: Node
619
) -> list[LN]:
620
    """Get header nodes for a compound statement that should be preserved inline."""
621
    if not _should_keep_compound_statement_inline(body_node, simple_stmt_parent):
6✔
622
        return []
6✔
623

624
    # Get the compound statement (parent of body)
625
    compound_stmt = body_node.parent
6✔
626
    if compound_stmt is None or compound_stmt.type not in _COMPOUND_STATEMENTS:
6!
627
        return []
×
628

629
    # Collect all header leaves before the body
630
    header_leaves: list[LN] = []
6✔
631
    for child in compound_stmt.children:
6!
632
        if child is body_node:
6✔
633
            break
6✔
634
        if isinstance(child, Leaf):
6✔
635
            if child.type not in (token.NEWLINE, token.INDENT):
6!
636
                header_leaves.append(child)
6✔
637
        else:
638
            header_leaves.extend(child.leaves())
6✔
639
    return header_leaves
6✔
640

641

642
def _find_closest_previous_sibling(node: LN) -> LN | None:
6✔
643
    """Find the closest previous sibling by walking up the ancestor chain."""
644
    current: LN | None = node
6✔
645
    while current is not None:
6!
646
        prev_sibling = current.prev_sibling
6✔
647
        if prev_sibling is not None:
6✔
648
            return prev_sibling
6✔
649
        current = current.parent
6✔
650
    return None
×
651

652

653
def _generate_ignored_nodes_from_fmt_skip(
6✔
654
    leaf: Leaf, comment: ProtoComment, mode: Mode
655
) -> Iterator[LN]:
656
    """Generate all leaves that should be ignored by the `# fmt: skip` from `leaf`."""
657
    prev_sibling = leaf.prev_sibling
6✔
658
    parent = leaf.parent
6✔
659
    ignored_nodes: list[LN] = []
6✔
660
    # Need to properly format the leaf prefix to compare it to comment.value,
661
    # which is also formatted
662
    comments = list_comments(leaf.prefix, is_endmarker=False, mode=mode)
6✔
663
    if not comments or comment.value != comments[0].value:
6!
664
        return
×
665

666
    if prev_sibling is None and parent is not None:
6✔
667
        prev_sibling = parent.prev_sibling
6✔
668

669
    if prev_sibling is None and comment.type == token.COMMENT:
6✔
670
        prev_sibling = _find_closest_previous_sibling(leaf)
6✔
671

672
    if parent is not None and parent.type == syms.suite and leaf.type == token.NEWLINE:
6✔
673
        # The `# fmt: skip` is on the colon line of the if/while/def/class/...
674
        # statements. The ignored nodes should be previous siblings of the
675
        # parent suite node. Do this before the generic "same physical line"
676
        # logic so multiline headers are preserved as a whole.
677
        leaf.prefix = ""
6✔
678
        parent_sibling = parent.prev_sibling
6✔
679
        while parent_sibling is not None and parent_sibling.type != syms.suite:
6✔
680
            ignored_nodes.insert(0, parent_sibling)
6✔
681
            parent_sibling = parent_sibling.prev_sibling
6✔
682
        # Special case for `async_stmt` where the ASYNC token is on the
683
        # grandparent node.
684
        grandparent = parent.parent
6✔
685
        if (
6✔
686
            grandparent is not None
687
            and grandparent.prev_sibling is not None
688
            and grandparent.prev_sibling.type == token.ASYNC
689
        ):
690
            ignored_nodes.insert(0, grandparent.prev_sibling)
6✔
691
        yield from iter(ignored_nodes)
6✔
692
    elif prev_sibling is not None:
6✔
693
        # Generates the nodes to be ignored by `fmt: skip`.
694

695
        # Nodes to ignore are the ones on the same line as the
696
        # `# fmt: skip` comment, excluding the `# fmt: skip`
697
        # node itself.
698

699
        # Traversal process (starting at the `# fmt: skip` node):
700
        # 1. Move to the `prev_sibling` of the current node.
701
        # 2. If `prev_sibling` has children, go to its rightmost leaf.
702
        # 3. If there's no `prev_sibling`, move up to the parent
703
        # node and repeat.
704
        # 4. Continue until:
705
        #    a. You encounter an `INDENT` or `NEWLINE` node (indicates
706
        #       start of the line).
707
        #    b. You reach the root node.
708

709
        # Include all visited LEAVES in the ignored list, except INDENT
710
        # or NEWLINE leaves.
711

712
        current_node = prev_sibling
6✔
713
        if (
6✔
714
            isinstance(current_node, Leaf)
715
            and current_node.type in OPENING_BRACKETS
716
            and current_node.parent
717
            and current_node.parent.type == syms.atom
718
        ):
719
            current_node = current_node.parent
6✔
720

721
        ignored_nodes = [current_node]
6✔
722
        if current_node.prev_sibling is None and current_node.parent is not None:
6✔
723
            current_node = current_node.parent
6✔
724

725
        # Track seen nodes to detect cycles that can occur after tree modifications
726
        seen_nodes = {id(current_node)}
6✔
727

728
        while "\n" not in current_node.prefix and current_node.prev_sibling is not None:
6✔
729
            leaf_nodes = list(current_node.prev_sibling.leaves())
6✔
730
            next_node = leaf_nodes[-1] if leaf_nodes else current_node
6✔
731

732
            # Detect infinite loop - if we've seen this node before, stop
733
            # This can happen when STANDALONE_COMMENT nodes are inserted
734
            # during processing
735
            if id(next_node) in seen_nodes:
6!
736
                break
×
737

738
            current_node = next_node
6✔
739
            seen_nodes.add(id(current_node))
6✔
740

741
            # Stop if we encounter a STANDALONE_COMMENT created by fmt processing
742
            if (
6✔
743
                isinstance(current_node, Leaf)
744
                and current_node.type == STANDALONE_COMMENT
745
                and hasattr(current_node, "fmt_pass_converted_first_leaf")
746
            ):
747
                break
6✔
748

749
            if (
6✔
750
                current_node.type in CLOSING_BRACKETS
751
                and current_node.parent
752
                and current_node.parent.type == syms.atom
753
            ):
754
                current_node = current_node.parent
6✔
755

756
            if current_node.type in (token.NEWLINE, token.INDENT):
6✔
757
                if not list_comments(
6✔
758
                    current_node.prefix, is_endmarker=False, mode=mode
759
                ):
760
                    current_node.prefix = ""
6✔
761
                break
6✔
762

763
            if current_node.type == token.DEDENT:
6!
UNCOV
764
                break
×
765

766
            # Special case for with expressions
767
            # Without this, we can stuck inside the asexpr_test's children's children
768
            if (
6✔
769
                current_node.parent
770
                and current_node.parent.type == syms.asexpr_test
771
                and current_node.parent.parent
772
                and current_node.parent.parent.type == syms.with_stmt
773
            ):
774
                current_node = current_node.parent
6✔
775

776
            ignored_nodes.insert(0, current_node)
6✔
777

778
            if current_node.prev_sibling is None and current_node.parent is not None:
6✔
779
                current_node = current_node.parent
6✔
780

781
        # Special handling for compound statements with semicolon-separated bodies
782
        if isinstance(parent, Node):
6!
783
            body_node = _find_compound_statement_context(parent)
6✔
784
            if body_node is not None:
6✔
785
                header_nodes = _get_compound_statement_header(body_node, parent)
6✔
786
                if header_nodes:
6✔
787
                    ignored_nodes = header_nodes + ignored_nodes
6✔
788

789
        leaf_is_ignored = any(
6✔
790
            ignored is leaf
791
            or (
792
                isinstance(ignored, Node)
793
                and any(child is leaf for child in ignored.leaves())
794
            )
795
            for ignored in ignored_nodes
796
        )
797
        if not leaf_is_ignored:
6✔
798
            leaf.prefix = leaf.prefix[comment.consumed :]
6✔
799

800
        yield from ignored_nodes
6✔
801

802

803
def is_fmt_on(container: LN, mode: Mode) -> bool:
6✔
804
    """Determine whether formatting is switched on within a container.
805
    Determined by whether the last `# fmt:` comment is `on` or `off`.
806
    """
807
    fmt_on = False
6✔
808
    for comment in list_comments(container.prefix, is_endmarker=False, mode=mode):
6✔
809
        if contains_fmt_directive(comment.value, FMT_ON):
6✔
810
            fmt_on = True
6✔
811
        elif contains_fmt_directive(comment.value, FMT_OFF):
6✔
812
            fmt_on = False
6✔
813
    return fmt_on
6✔
814

815

816
def children_contains_fmt_on(container: LN, mode: Mode) -> bool:
6✔
817
    """Determine if children have formatting switched on."""
818
    for child in container.children:
6✔
819
        leaf = first_leaf_of(child)
6✔
820
        if leaf is not None and is_fmt_on(leaf, mode=mode):
6✔
821
            return True
6✔
822

823
    return False
6✔
824

825

826
def contains_pragma_comment(comment_list: list[Leaf]) -> bool:
6✔
827
    """
828
    Returns:
829
        True iff one of the comments in @comment_list is a pragma used by one
830
        of the more common static analysis tools for python (e.g. mypy, flake8,
831
        pylint).
832
    """
833
    for comment in comment_list:
6✔
834
        if comment.value.startswith(("# type:", "# noqa", "# pylint:")):
6✔
835
            return True
6✔
836

837
    return False
6✔
838

839

840
def contains_fmt_directive(
6✔
841
    comment_line: str, directives: set[str] = FMT_OFF | FMT_ON | FMT_SKIP
842
) -> bool:
843
    """
844
    Checks if the given comment contains format directives, alone or paired with
845
    other comments.
846

847
    Defaults to checking all directives (skip, off, on, yapf), but can be
848
    narrowed to specific ones.
849

850
    Matching styles:
851
      # foobar                    <-- single comment
852
      # foobar # foobar # foobar  <-- multiple comments
853
      # foobar; foobar            <-- list of comments (; separated)
854
    """
855
    semantic_comment_blocks = [
6✔
856
        comment_line,
857
        *[
858
            _COMMENT_PREFIX + comment.strip()
859
            for comment in comment_line.split(_COMMENT_PREFIX)[1:]
860
        ],
861
        *[
862
            _COMMENT_PREFIX + comment.strip()
863
            for comment in comment_line.strip(_COMMENT_PREFIX).split(
864
                _COMMENT_LIST_SEPARATOR
865
            )
866
        ],
867
    ]
868

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