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

psf / black / 21055815154

16 Jan 2026 04:38AM UTC coverage: 95.849% (-0.02%) from 95.871%
21055815154

Pull #4903

github

web-flow
Merge 0b636fc37 into 21a2a8c2b
Pull Request #4903: Improve fmt:skip handling in nested expressions with checks

5192 of 5471 branches covered (94.9%)

9 of 11 new or added lines in 1 file covered. (81.82%)

2 existing lines in 2 files now uncovered.

7967 of 8312 relevant lines covered (95.85%)

4.79 hits per line

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

97.53
/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
4✔
52
    value: str  # content of the comment
4✔
53
    newlines: int  # how many newlines before the comment
4✔
54
    consumed: int  # how many characters of the original leaf's prefix did we consume
4✔
55
    form_feed: bool  # is there a form feed before the comment
4✔
56
    leading_whitespace: str  # leading whitespace before the comment, if any
4✔
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/skip processing
343
        # to avoid reprocessing them in subsequent iterations
344
        if leaf.type == STANDALONE_COMMENT and hasattr(
5✔
345
            leaf, "fmt_pass_converted_first_leaf"
346
        ):
347
            continue
5✔
348

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

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

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

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

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

378
            # Handle regular fmt blocks
379

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

390
    return False
5✔
391

392

393
def _handle_regular_fmt_block(
5✔
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`.
5✔
403
    parent = first.parent
5✔
404
    prefix = first.prefix
5✔
405

406
    if contains_fmt_directive(comment.value, FMT_OFF):
5✔
407
        first.prefix = prefix[comment.consumed :]
5✔
408
    if is_fmt_skip:
5✔
409
        first.prefix = ""
5✔
410
        standalone_comment_prefix = prefix
5✔
411
    else:
412
        standalone_comment_prefix = prefix[:previous_consumed] + "\n" * comment.newlines
5✔
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 = []
5✔
417
    for node in ignored_nodes:
5✔
418
        if isinstance(node, Leaf) and node.type == STANDALONE_COMMENT:
5✔
419
            # Add newline after STANDALONE_COMMENT Leaf
420
            node_str = str(node)
5✔
421
            if not node_str.endswith("\n"):
5!
422
                node_str += "\n"
5✔
423
            parts.append(node_str)
5✔
424
        elif isinstance(node, Node):
5✔
425
            # For nodes that might contain STANDALONE_COMMENT leaves,
426
            # we need custom stringify
427
            has_standalone = any(
5✔
428
                leaf.type == STANDALONE_COMMENT for leaf in node.leaves()
429
            )
430
            if has_standalone:
5✔
431
                # Stringify node with STANDALONE_COMMENT leaves having trailing newlines
432
                def stringify_node(n: LN) -> str:
5✔
433
                    if isinstance(n, Leaf):
5✔
434
                        if n.type == STANDALONE_COMMENT:
5✔
435
                            result = n.prefix + n.value
5✔
436
                            if not result.endswith("\n"):
5!
437
                                result += "\n"
5✔
438
                            return result
5✔
439
                        return str(n)
5✔
440
                    else:
441
                        # For nested nodes, recursively process children
442
                        return "".join(stringify_node(child) for child in n.children)
5✔
443

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

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

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

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

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

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

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

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

492

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

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

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

541

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

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

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

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

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

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

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

580
    return None
5✔
581

582

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

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

608

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

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

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

633

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

647
    if Preview.fix_fmt_skip_in_one_liners in mode and not prev_sibling and parent:
5✔
648
        # Simple one-level check: if the leaf has no prev_sibling,
649
        # check parent's prev_sibling. This handles some nested cases
650
        # without the complexity and indentation issues of deeper tree climbing
651
        prev_sibling = parent.prev_sibling
5✔
652

653
    if prev_sibling is not None:
5✔
654
        leaf.prefix = leaf.prefix[comment.consumed :]
5✔
655

656
        if Preview.fix_fmt_skip_in_one_liners not in mode:
5✔
657
            siblings = [prev_sibling]
5✔
658
            while (
5✔
659
                "\n" not in prev_sibling.prefix
660
                and prev_sibling.prev_sibling is not None
661
            ):
662
                prev_sibling = prev_sibling.prev_sibling
5✔
663
                siblings.insert(0, prev_sibling)
5✔
664
            yield from siblings
5✔
665
            return
5✔
666

667
        # Generates the nodes to be ignored by `fmt: skip`.
668

669
        # Nodes to ignore are the ones on the same line as the
670
        # `# fmt: skip` comment, excluding the `# fmt: skip`
671
        # node itself.
672

673
        # Traversal process (starting at the `# fmt: skip` node):
674
        # 1. Move to the `prev_sibling` of the current node.
675
        # 2. If `prev_sibling` has children, go to its rightmost leaf.
676
        # 3. If there's no `prev_sibling`, move up to the parent
677
        # node and repeat.
678
        # 4. Continue until:
679
        #    a. You encounter an `INDENT` or `NEWLINE` node (indicates
680
        #       start of the line).
681
        #    b. You reach the root node.
682

683
        # Include all visited LEAVES in the ignored list, except INDENT
684
        # or NEWLINE leaves.
685

686
        current_node = prev_sibling
5✔
687
        ignored_nodes = [current_node]
5✔
688
        if current_node.prev_sibling is None and current_node.parent is not None:
5✔
689
            current_node = current_node.parent
5✔
690

691
        # Track seen nodes to detect cycles that can occur after tree modifications
692
        seen_nodes = {id(current_node)}
5✔
693

694
        while "\n" not in current_node.prefix and current_node.prev_sibling is not None:
5✔
695
            leaf_nodes = list(current_node.prev_sibling.leaves())
5✔
696
            next_node = leaf_nodes[-1] if leaf_nodes else current_node
5✔
697

698
            # Detect infinite loop - if we've seen this node before, stop
699
            # This can happen when STANDALONE_COMMENT nodes are inserted
700
            # during processing
701
            if id(next_node) in seen_nodes:
5!
UNCOV
702
                break
×
703

704
            current_node = next_node
5✔
705
            seen_nodes.add(id(current_node))
5✔
706

707
            # Stop if we encounter a STANDALONE_COMMENT created by fmt processing
708
            if (
5✔
709
                isinstance(current_node, Leaf)
710
                and current_node.type == STANDALONE_COMMENT
711
                and hasattr(current_node, "fmt_pass_converted_first_leaf")
712
            ):
713
                break
5✔
714

715
            if (
5✔
716
                current_node.type in CLOSING_BRACKETS
717
                and current_node.parent
718
                and current_node.parent.type == syms.atom
719
            ):
720
                current_node = current_node.parent
5✔
721

722
            if current_node.type in (token.NEWLINE, token.INDENT):
5✔
723
                current_node.prefix = ""
5✔
724
                break
5✔
725

726
            if current_node.type == token.DEDENT:
5✔
727
                break
5✔
728

729
            # Special case for with expressions
730
            # Without this, we can stuck inside the asexpr_test's children's children
731
            if (
5✔
732
                current_node.parent
733
                and current_node.parent.type == syms.asexpr_test
734
                and current_node.parent.parent
735
                and current_node.parent.parent.type == syms.with_stmt
736
            ):
737
                current_node = current_node.parent
5✔
738

739
            ignored_nodes.insert(0, current_node)
5✔
740

741
            if current_node.prev_sibling is None and current_node.parent is not None:
5✔
742
                current_node = current_node.parent
5✔
743

744
        # Special handling for compound statements with semicolon-separated bodies
745
        if Preview.fix_fmt_skip_in_one_liners in mode and isinstance(parent, Node):
5!
746
            body_node = _find_compound_statement_context(parent)
5✔
747
            if body_node is not None:
5✔
748
                header_nodes = _get_compound_statement_header(body_node, parent)
5✔
749
                if header_nodes:
5✔
750
                    ignored_nodes = header_nodes + ignored_nodes
5✔
751

752
        yield from ignored_nodes
5✔
753
    elif (
5✔
754
        parent is not None and parent.type == syms.suite and leaf.type == token.NEWLINE
755
    ):
756
        # The `# fmt: skip` is on the colon line of the if/while/def/class/...
757
        # statements. The ignored nodes should be previous siblings of the
758
        # parent suite node.
759
        leaf.prefix = ""
5✔
760
        parent_sibling = parent.prev_sibling
5✔
761
        while parent_sibling is not None and parent_sibling.type != syms.suite:
5✔
762
            ignored_nodes.insert(0, parent_sibling)
5✔
763
            parent_sibling = parent_sibling.prev_sibling
5✔
764
        # Special case for `async_stmt` where the ASYNC token is on the
765
        # grandparent node.
766
        grandparent = parent.parent
5✔
767
        if (
5✔
768
            grandparent is not None
769
            and grandparent.prev_sibling is not None
770
            and grandparent.prev_sibling.type == token.ASYNC
771
        ):
772
            ignored_nodes.insert(0, grandparent.prev_sibling)
5✔
773
        yield from iter(ignored_nodes)
5✔
774

775

776
def is_fmt_on(container: LN, mode: Mode) -> bool:
5✔
777
    """Determine whether formatting is switched on within a container.
778
    Determined by whether the last `# fmt:` comment is `on` or `off`.
779
    """
780
    fmt_on = False
5✔
781
    for comment in list_comments(container.prefix, is_endmarker=False, mode=mode):
5✔
782
        if contains_fmt_directive(comment.value, FMT_ON):
5✔
783
            fmt_on = True
5✔
784
        elif contains_fmt_directive(comment.value, FMT_OFF):
5✔
785
            fmt_on = False
5✔
786
    return fmt_on
5✔
787

788

789
def children_contains_fmt_on(container: LN, mode: Mode) -> bool:
5✔
790
    """Determine if children have formatting switched on."""
791
    for child in container.children:
5✔
792
        leaf = first_leaf_of(child)
5✔
793
        if leaf is not None and is_fmt_on(leaf, mode=mode):
5✔
794
            return True
5✔
795

796
    return False
5✔
797

798

799
def contains_pragma_comment(comment_list: list[Leaf]) -> bool:
5✔
800
    """
801
    Returns:
802
        True iff one of the comments in @comment_list is a pragma used by one
803
        of the more common static analysis tools for python (e.g. mypy, flake8,
804
        pylint).
805
    """
806
    for comment in comment_list:
5✔
807
        if comment.value.startswith(("# type:", "# noqa", "# pylint:")):
5✔
808
            return True
5✔
809

810
    return False
5✔
811

812

813
def contains_fmt_directive(
5✔
814
    comment_line: str, directives: set[str] = FMT_OFF | FMT_ON | FMT_SKIP
815
) -> bool:
816
    """
817
    Checks if the given comment contains format directives, alone or paired with
818
    other comments.
819

820
    Defaults to checking all directives (skip, off, on, yapf), but can be
821
    narrowed to specific ones.
822

823
    Matching styles:
824
      # foobar                    <-- single comment
825
      # foobar # foobar # foobar  <-- multiple comments
826
      # foobar; foobar            <-- list of comments (; separated)
827
    """
828
    semantic_comment_blocks = [
5✔
829
        comment_line,
830
        *[
831
            _COMMENT_PREFIX + comment.strip()
832
            for comment in comment_line.split(_COMMENT_PREFIX)[1:]
833
        ],
834
        *[
835
            _COMMENT_PREFIX + comment.strip()
836
            for comment in comment_line.strip(_COMMENT_PREFIX).split(
837
                _COMMENT_LIST_SEPARATOR
838
            )
839
        ],
840
    ]
841

842
    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