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

psf / black / 19954421854

05 Dec 2025 06:10AM UTC coverage: 95.748% (-0.08%) from 95.831%
19954421854

Pull #4883

github

web-flow
Merge 49ae8062b into 1b342ef5b
Pull Request #4883: Fix `# fmt: skip` ignored in deeply nested expressions

5212 of 5498 branches covered (94.8%)

28 of 36 new or added lines in 2 files covered. (77.78%)

6 existing lines in 2 files now uncovered.

7927 of 8279 relevant lines covered (95.75%)

4.79 hits per line

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

95.66
/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
    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
5✔
22
from blib2to3.pytree import Leaf, Node
5✔
23

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

27
FMT_OFF: Final = {"# fmt: off", "# fmt:off", "# yapf: disable"}
5✔
28
FMT_SKIP: Final = {"# fmt: skip", "# fmt:skip"}
5✔
29
FMT_ON: Final = {"# fmt: on", "# fmt:on", "# yapf: enable"}
5✔
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}
5✔
34

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

39

40
@dataclass
5✔
41
class ProtoComment:
5✔
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
5✔
53
    value: str  # content of the comment
5✔
54
    newlines: int  # how many newlines before the comment
5✔
55
    consumed: int  # how many characters of the original leaf's prefix did we consume
5✔
56
    form_feed: bool  # is there a form feed before the comment
5✔
57
    leading_whitespace: str  # leading whitespace before the comment, if any
5✔
58

59

60
def generate_comments(leaf: LN, mode: Mode) -> Iterator[Leaf]:
5✔
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
5✔
80
    for pc in list_comments(
5✔
81
        leaf.prefix, is_endmarker=leaf.type == token.ENDMARKER, mode=mode
82
    ):
83
        total_consumed = pc.consumed
5✔
84
        prefix = make_simple_prefix(pc.newlines, pc.form_feed)
5✔
85
        yield Leaf(pc.type, pc.value, prefix=prefix)
5✔
86
    normalize_trailing_prefix(leaf, total_consumed)
5✔
87

88

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

96
    consumed = 0
5✔
97
    nlines = 0
5✔
98
    ignored_lines = 0
5✔
99
    form_feed = False
5✔
100
    for index, full_line in enumerate(re.split("\r?\n|\r", prefix)):
5✔
101
        consumed += len(full_line) + 1  # adding the length of the split '\n'
5✔
102
        match = re.match(r"^(\s*)(\S.*|)$", full_line)
5✔
103
        assert match
5✔
104
        whitespace, line = match.groups()
5✔
105
        if not line:
5✔
106
            nlines += 1
5✔
107
            if "\f" in full_line:
5✔
108
                form_feed = True
5✔
109
        if not line.startswith("#"):
5✔
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("\\"):
5✔
114
                ignored_lines += 1
5✔
115
            continue
5✔
116

117
        if index == ignored_lines and not is_endmarker:
5✔
118
            comment_type = token.COMMENT  # simple trailing comment
5✔
119
        else:
120
            comment_type = STANDALONE_COMMENT
5✔
121
        comment = make_comment(line, mode=mode)
5✔
122
        result.append(
5✔
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
5✔
133
        nlines = 0
5✔
134
    return result
5✔
135

136

137
def normalize_trailing_prefix(leaf: LN, total_consumed: int) -> None:
5✔
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:]
5✔
143
    if "\\" not in remainder:
5✔
144
        nl_count = remainder.count("\n")
5✔
145
        form_feed = "\f" in remainder and remainder.endswith("\n")
5✔
146
        leaf.prefix = make_simple_prefix(nl_count, form_feed)
5✔
147
        return
5✔
148

149
    leaf.prefix = ""
5✔
150

151

152
def make_comment(content: str, mode: Mode) -> str:
5✔
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()
5✔
164
    if not content:
5!
165
        return "#"
×
166

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

171
    if content[0] == "#":
5!
172
        content = content[1:]
5✔
173
    if (
5✔
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
5✔
179
    if (
5✔
180
        Preview.standardize_type_comments in mode
181
        and content
182
        and "\N{NO-BREAK SPACE}" not in content
183
        and is_type_comment_string("#" + content, mode=mode)
184
    ):
185
        type_part, value_part = content.split(":", 1)
5✔
186
        content = type_part.strip() + ": " + value_part.strip()
5✔
187

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

192

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

201

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

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

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

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

219
    return True, is_fmt_off, is_fmt_skip
5✔
220

221

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

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

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

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

243
    return True
5✔
244

245

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

334

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

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

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

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

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

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

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

379
            # Handle regular fmt blocks
380

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

391
    return False
5✔
392

393

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

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

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

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

451
    hidden_value = "".join(parts)
5✔
452
    comment_lineno = leaf.lineno - comment.newlines
5✔
453

454
    if contains_fmt_directive(comment.value, FMT_OFF):
5✔
455
        fmt_off_prefix = ""
5✔
456
        if len(lines) > 0 and not any(
5✔
457
            line[0] <= comment_lineno <= line[1] for line in lines
458
        ):
459
            # keeping indentation of comment by preserving original whitespaces.
460
            fmt_off_prefix = prefix.split(comment.value)[0]
5✔
461
            if "\n" in fmt_off_prefix:
5✔
462
                fmt_off_prefix = fmt_off_prefix.split("\n")[-1]
5✔
463
        standalone_comment_prefix += fmt_off_prefix
5✔
464
        hidden_value = comment.value + "\n" + hidden_value
5✔
465

466
    if is_fmt_skip:
5✔
467
        hidden_value += comment.leading_whitespace + comment.value
5✔
468

469
    if hidden_value.endswith("\n"):
5✔
470
        # That happens when one of the `ignored_nodes` ended with a NEWLINE
471
        # leaf (possibly followed by a DEDENT).
472
        hidden_value = hidden_value[:-1]
5✔
473

474
    first_idx: int | None = None
5✔
475
    for ignored in ignored_nodes:
5✔
476
        index = ignored.remove()
5✔
477
        if first_idx is None:
5✔
478
            first_idx = index
5✔
479

480
    assert parent is not None, "INTERNAL ERROR: fmt: on/off handling (1)"
5✔
481
    assert first_idx is not None, "INTERNAL ERROR: fmt: on/off handling (2)"
5✔
482

483
    parent.insert_child(
5✔
484
        first_idx,
485
        Leaf(
486
            STANDALONE_COMMENT,
487
            hidden_value,
488
            prefix=standalone_comment_prefix,
489
            fmt_pass_converted_first_leaf=first_leaf_of(first),
490
        ),
491
    )
492

493

494
def generate_ignored_nodes(
5✔
495
    leaf: Leaf, comment: ProtoComment, mode: Mode
496
) -> Iterator[LN]:
497
    """Starting from the container of `leaf`, generate all leaves until `# fmt: on`.
498

499
    If comment is skip, returns leaf only.
500
    Stops at the end of the block.
501
    """
502
    if contains_fmt_directive(comment.value, FMT_SKIP):
5✔
503
        yield from _generate_ignored_nodes_from_fmt_skip(leaf, comment, mode)
5✔
504
        return
5✔
505
    container: LN | None = container_of(leaf)
5✔
506
    while container is not None and container.type != token.ENDMARKER:
5✔
507
        if is_fmt_on(container, mode=mode):
5✔
508
            return
5✔
509

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

542

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

546
    This handles one-line compound statements like:
547
        if condition: body  # fmt: skip
548

549
    When Black expands such statements, they temporarily look like:
550
        if condition:
551
            body  # fmt: skip
552

553
    In both cases, we want to return the body node (either the simple_stmt directly
554
    or the suite containing it).
555
    """
556
    if parent.type != syms.simple_stmt:
5✔
557
        return None
5✔
558

559
    if not isinstance(parent.parent, Node):
5!
560
        return None
×
561

562
    # Case 1: Expanded form after Black's initial formatting pass.
563
    # The one-liner has been split across multiple lines:
564
    #     if True:
565
    #         print("a"); print("b")  # fmt: skip
566
    # Structure: compound_stmt -> suite -> simple_stmt
567
    if (
5✔
568
        parent.parent.type == syms.suite
569
        and isinstance(parent.parent.parent, Node)
570
        and parent.parent.parent.type in _COMPOUND_STATEMENTS
571
    ):
572
        return parent.parent
5✔
573

574
    # Case 2: Original one-line form from the input source.
575
    # The statement is still on a single line:
576
    #     if True: print("a"); print("b")  # fmt: skip
577
    # Structure: compound_stmt -> simple_stmt
578
    if parent.parent.type in _COMPOUND_STATEMENTS:
5✔
579
        return parent
5✔
580

581
    return None
5✔
582

583

584
def _should_keep_compound_statement_inline(
5✔
585
    body_node: Node, simple_stmt_parent: Node
586
) -> bool:
587
    """Check if a compound statement should be kept on one line.
588

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

609

610
def _get_compound_statement_header(
5✔
611
    body_node: Node, simple_stmt_parent: Node
612
) -> list[LN]:
613
    """Get header nodes for a compound statement that should be preserved inline."""
614
    if not _should_keep_compound_statement_inline(body_node, simple_stmt_parent):
5✔
615
        return []
5✔
616

617
    # Get the compound statement (parent of body)
618
    compound_stmt = body_node.parent
5✔
619
    if compound_stmt is None or compound_stmt.type not in _COMPOUND_STATEMENTS:
5!
620
        return []
×
621

622
    # Collect all header leaves before the body
623
    header_leaves: list[LN] = []
5✔
624
    for child in compound_stmt.children:
5!
625
        if child is body_node:
5✔
626
            break
5✔
627
        if isinstance(child, Leaf):
5✔
628
            if child.type not in (token.NEWLINE, token.INDENT):
5!
629
                header_leaves.append(child)
5✔
630
        else:
631
            header_leaves.extend(child.leaves())
5✔
632
    return header_leaves
5✔
633

634

635
def _generate_ignored_nodes_from_fmt_skip(
5✔
636
    leaf: Leaf, comment: ProtoComment, mode: Mode
637
) -> Iterator[LN]:
638
    """Generate all leaves that should be ignored by the `# fmt: skip` from `leaf`."""
639
    prev_sibling = leaf.prev_sibling
5✔
640
    parent = leaf.parent
5✔
641
    ignored_nodes: list[LN] = []
5✔
642
    # Need to properly format the leaf prefix to compare it to comment.value,
643
    # which is also formatted
644
    comments = list_comments(leaf.prefix, is_endmarker=False, mode=mode)
5✔
645
    if not comments or comment.value != comments[0].value:
5!
646
        return
×
647

648
    if Preview.fix_fmt_skip_in_one_liners in mode and not prev_sibling and parent:
5✔
649
        # If the current leaf doesn't have a previous sibling, it might be deeply nested
650
        # (e.g. inside a list, function call, etc.). We need to climb up the tree
651
        # to find the previous sibling on the same line.
652

653
        # First, find the ancestor node that starts the current line
654
        # (has a newline in its prefix, or is at the root)
655
        line_start_node = parent
5✔
656
        while line_start_node.parent is not None:
5!
657
            # The comment itself is in the leaf's prefix, so we need to check
658
            # if the current node's prefix (before the comment) has a newline
659
            node_prefix = (
5✔
660
                line_start_node.prefix if hasattr(line_start_node, "prefix") else ""
661
            )
662
            # Skip the comment part if this is the first node (parent)
663
            if line_start_node == parent:
5✔
664
                # The parent node has the comment in its prefix, so we check
665
                # what's before the comment
666
                comment_start = node_prefix.find(comment.value)
5✔
667
                if comment_start >= 0:
5!
668
                    prefix_before_comment = node_prefix[:comment_start]
5✔
669
                    if "\n" in prefix_before_comment:
5✔
670
                        # There's a newline before the comment, so this node
671
                        # is at the start of the line
672
                        break
5✔
NEW
673
                elif "\n" in node_prefix:
×
NEW
674
                    break
×
675
            elif "\n" in node_prefix:
5✔
676
                # This node starts on a new line, so it's the line start
677
                break
5✔
678
            line_start_node = line_start_node.parent
5✔
679

680
        # Now find the prev_sibling by climbing from parent up to line_start_node
681
        curr = parent
5✔
682
        while curr is not None and curr != line_start_node.parent:
5✔
683
            if curr.prev_sibling is not None:
5✔
684
                prev_sibling = curr.prev_sibling
5✔
685
                break
5✔
686
            if curr.parent is None:
5!
NEW
687
                break
×
688
            curr = curr.parent
5✔
689

690
    if prev_sibling is not None:
5✔
691
        leaf.prefix = leaf.prefix[comment.consumed :]
5✔
692

693
        if Preview.fix_fmt_skip_in_one_liners not in mode:
5✔
694
            siblings = [prev_sibling]
5✔
695
            while (
5✔
696
                "\n" not in prev_sibling.prefix
697
                and prev_sibling.prev_sibling is not None
698
            ):
699
                prev_sibling = prev_sibling.prev_sibling
5✔
700
                siblings.insert(0, prev_sibling)
5✔
701
            yield from siblings
5✔
702
            return
5✔
703

704
        # Generates the nodes to be ignored by `fmt: skip`.
705

706
        # Nodes to ignore are the ones on the same line as the
707
        # `# fmt: skip` comment, excluding the `# fmt: skip`
708
        # node itself.
709

710
        # Traversal process (starting at the `# fmt: skip` node):
711
        # 1. Move to the `prev_sibling` of the current node.
712
        # 2. If `prev_sibling` has children, go to its rightmost leaf.
713
        # 3. If there's no `prev_sibling`, move up to the parent
714
        # node and repeat.
715
        # 4. Continue until:
716
        #    a. You encounter an `INDENT` or `NEWLINE` node (indicates
717
        #       start of the line).
718
        #    b. You reach the root node.
719

720
        # Include all visited LEAVES in the ignored list, except INDENT
721
        # or NEWLINE leaves.
722

723
        current_node = prev_sibling
5✔
724
        ignored_nodes = [current_node]
5✔
725
        if current_node.prev_sibling is None and current_node.parent is not None:
5✔
726
            current_node = current_node.parent
5✔
727

728
        # Track seen nodes to detect cycles that can occur after tree modifications
729
        seen_nodes = {id(current_node)}
5✔
730

731
        while "\n" not in current_node.prefix and current_node.prev_sibling is not None:
5✔
732
            leaf_nodes = list(current_node.prev_sibling.leaves())
5✔
733
            next_node = leaf_nodes[-1] if leaf_nodes else current_node
5✔
734

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

741
            current_node = next_node
5✔
742
            seen_nodes.add(id(current_node))
5✔
743

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

752
            if (
5✔
753
                current_node.type in CLOSING_BRACKETS
754
                and current_node.parent
755
                and current_node.parent.type == syms.atom
756
            ):
757
                current_node = current_node.parent
5✔
758

759
            if current_node.type in (token.NEWLINE, token.INDENT):
5✔
760
                current_node.prefix = ""
5✔
761
                break
5✔
762

763
            if current_node.type == token.DEDENT:
5✔
764
                break
5✔
765

766
            # Special case for with expressions
767
            # Without this, we can stuck inside the asexpr_test's children's children
768
            if (
5✔
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
5✔
775

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

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

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

789
        # If the leaf's parent is an atom (parenthesized expression) and we've
790
        # captured the opening bracket in our ignored_nodes, we should include
791
        # the entire atom (including the closing bracket and the leaf itself)
792
        # to avoid partial formatting
793
        if (
5!
794
            parent is not None
795
            and parent.type == syms.atom
796
            and len(parent.children) >= 2
797
            and parent.children[0].type in OPENING_BRACKETS
798
            and parent.children[0] in ignored_nodes
799
        ):
800
            # Replace the opening bracket and any other captured children of this atom
801
            # with the entire atom node
NEW
802
            ignored_nodes = [node for node in ignored_nodes if node.parent != parent]
×
NEW
803
            ignored_nodes.append(parent)
×
804

805
            # If the atom's parent is a binary operation (comparison, arithmetic, etc.)
806
            # and we've also captured siblings of the atom from the same parent, then
807
            # include the entire parent to avoid leaving an incomplete binary operation
NEW
UNCOV
808
            if (
×
809
                parent.parent is not None
810
                and isinstance(parent.parent, Node)
811
                and any(
812
                    node.parent == parent.parent and node != parent
813
                    for node in ignored_nodes
814
                )
815
            ):
NEW
UNCOV
816
                ignored_nodes = [
×
817
                    node for node in ignored_nodes if node.parent != parent.parent
818
                ]
NEW
UNCOV
819
                ignored_nodes.append(parent.parent)
×
820

821
        yield from ignored_nodes
5✔
822
    elif (
5✔
823
        parent is not None and parent.type == syms.suite and leaf.type == token.NEWLINE
824
    ):
825
        # The `# fmt: skip` is on the colon line of the if/while/def/class/...
826
        # statements. The ignored nodes should be previous siblings of the
827
        # parent suite node.
828
        leaf.prefix = ""
5✔
829
        parent_sibling = parent.prev_sibling
5✔
830
        while parent_sibling is not None and parent_sibling.type != syms.suite:
5✔
831
            ignored_nodes.insert(0, parent_sibling)
5✔
832
            parent_sibling = parent_sibling.prev_sibling
5✔
833
        # Special case for `async_stmt` where the ASYNC token is on the
834
        # grandparent node.
835
        grandparent = parent.parent
5✔
836
        if (
5✔
837
            grandparent is not None
838
            and grandparent.prev_sibling is not None
839
            and grandparent.prev_sibling.type == token.ASYNC
840
        ):
841
            ignored_nodes.insert(0, grandparent.prev_sibling)
5✔
842
        yield from iter(ignored_nodes)
5✔
843

844

845
def is_fmt_on(container: LN, mode: Mode) -> bool:
5✔
846
    """Determine whether formatting is switched on within a container.
847
    Determined by whether the last `# fmt:` comment is `on` or `off`.
848
    """
849
    fmt_on = False
5✔
850
    for comment in list_comments(container.prefix, is_endmarker=False, mode=mode):
5✔
851
        if contains_fmt_directive(comment.value, FMT_ON):
5✔
852
            fmt_on = True
5✔
853
        elif contains_fmt_directive(comment.value, FMT_OFF):
5✔
854
            fmt_on = False
5✔
855
    return fmt_on
5✔
856

857

858
def children_contains_fmt_on(container: LN, mode: Mode) -> bool:
5✔
859
    """Determine if children have formatting switched on."""
860
    for child in container.children:
5✔
861
        leaf = first_leaf_of(child)
5✔
862
        if leaf is not None and is_fmt_on(leaf, mode=mode):
5✔
863
            return True
5✔
864

865
    return False
5✔
866

867

868
def contains_pragma_comment(comment_list: list[Leaf]) -> bool:
5✔
869
    """
870
    Returns:
871
        True iff one of the comments in @comment_list is a pragma used by one
872
        of the more common static analysis tools for python (e.g. mypy, flake8,
873
        pylint).
874
    """
875
    for comment in comment_list:
5✔
876
        if comment.value.startswith(("# type:", "# noqa", "# pylint:")):
5✔
877
            return True
5✔
878

879
    return False
5✔
880

881

882
def contains_fmt_directive(
5✔
883
    comment_line: str, directives: set[str] = FMT_OFF | FMT_ON | FMT_SKIP
884
) -> bool:
885
    """
886
    Checks if the given comment contains format directives, alone or paired with
887
    other comments.
888

889
    Defaults to checking all directives (skip, off, on, yapf), but can be
890
    narrowed to specific ones.
891

892
    Matching styles:
893
      # foobar                    <-- single comment
894
      # foobar # foobar # foobar  <-- multiple comments
895
      # foobar; foobar            <-- list of comments (; separated)
896
    """
897
    semantic_comment_blocks = [
5✔
898
        comment_line,
899
        *[
900
            _COMMENT_PREFIX + comment.strip()
901
            for comment in comment_line.split(_COMMENT_PREFIX)[1:]
902
        ],
903
        *[
904
            _COMMENT_PREFIX + comment.strip()
905
            for comment in comment_line.strip(_COMMENT_PREFIX).split(
906
                _COMMENT_LIST_SEPARATOR
907
            )
908
        ],
909
    ]
910

911
    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