Skip to content

Commit 8091b25

Browse files
authored
Correctly handle trailing commas that are inside a line's leading non-nested parens (#3370)
- Fixes #1671 - Fixes #3229
1 parent ffaaf48 commit 8091b25

File tree

8 files changed

+240
-16
lines changed

8 files changed

+240
-16
lines changed

CHANGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
- Enforce empty lines before classes and functions with sticky leading comments (#3302)
1818
- Implicitly concatenated strings used as function args are now wrapped inside
1919
parentheses (#3307)
20+
- Correctly handle trailing commas that are inside a line's leading non-nested parens
21+
(#3370)
2022

2123
### Configuration
2224

src/black/brackets.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import sys
44
from dataclasses import dataclass, field
5-
from typing import Dict, Iterable, List, Optional, Tuple, Union
5+
from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple, Union
66

77
if sys.version_info < (3, 8):
88
from typing_extensions import Final
@@ -340,3 +340,35 @@ def max_delimiter_priority_in_atom(node: LN) -> Priority:
340340

341341
except ValueError:
342342
return 0
343+
344+
345+
def get_leaves_inside_matching_brackets(leaves: Sequence[Leaf]) -> Set[LeafID]:
346+
"""Return leaves that are inside matching brackets.
347+
348+
The input `leaves` can have non-matching brackets at the head or tail parts.
349+
Matching brackets are included.
350+
"""
351+
try:
352+
# Only track brackets from the first opening bracket to the last closing
353+
# bracket.
354+
start_index = next(
355+
i for i, l in enumerate(leaves) if l.type in OPENING_BRACKETS
356+
)
357+
end_index = next(
358+
len(leaves) - i
359+
for i, l in enumerate(reversed(leaves))
360+
if l.type in CLOSING_BRACKETS
361+
)
362+
except StopIteration:
363+
return set()
364+
ids = set()
365+
depth = 0
366+
for i in range(end_index, start_index - 1, -1):
367+
leaf = leaves[i]
368+
if leaf.type in CLOSING_BRACKETS:
369+
depth += 1
370+
if depth > 0:
371+
ids.add(id(leaf))
372+
if leaf.type in OPENING_BRACKETS:
373+
depth -= 1
374+
return ids

src/black/linegen.py

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@
22
Generating lines of code.
33
"""
44
import sys
5+
from enum import Enum, auto
56
from functools import partial, wraps
67
from typing import Collection, Iterator, List, Optional, Set, Union, cast
78

8-
from black.brackets import COMMA_PRIORITY, DOT_PRIORITY, max_delimiter_priority_in_atom
9+
from black.brackets import (
10+
COMMA_PRIORITY,
11+
DOT_PRIORITY,
12+
get_leaves_inside_matching_brackets,
13+
max_delimiter_priority_in_atom,
14+
)
915
from black.comments import FMT_OFF, generate_comments, list_comments
1016
from black.lines import (
1117
Line,
@@ -561,6 +567,12 @@ def _rhs(
561567
yield line
562568

563569

570+
class _BracketSplitComponent(Enum):
571+
head = auto()
572+
body = auto()
573+
tail = auto()
574+
575+
564576
def left_hand_split(line: Line, _features: Collection[Feature] = ()) -> Iterator[Line]:
565577
"""Split line into many lines, starting with the first matching bracket pair.
566578
@@ -591,9 +603,15 @@ def left_hand_split(line: Line, _features: Collection[Feature] = ()) -> Iterator
591603
if not matching_bracket:
592604
raise CannotSplit("No brackets found")
593605

594-
head = bracket_split_build_line(head_leaves, line, matching_bracket)
595-
body = bracket_split_build_line(body_leaves, line, matching_bracket, is_body=True)
596-
tail = bracket_split_build_line(tail_leaves, line, matching_bracket)
606+
head = bracket_split_build_line(
607+
head_leaves, line, matching_bracket, component=_BracketSplitComponent.head
608+
)
609+
body = bracket_split_build_line(
610+
body_leaves, line, matching_bracket, component=_BracketSplitComponent.body
611+
)
612+
tail = bracket_split_build_line(
613+
tail_leaves, line, matching_bracket, component=_BracketSplitComponent.tail
614+
)
597615
bracket_split_succeeded_or_raise(head, body, tail)
598616
for result in (head, body, tail):
599617
if result:
@@ -639,9 +657,15 @@ def right_hand_split(
639657
tail_leaves.reverse()
640658
body_leaves.reverse()
641659
head_leaves.reverse()
642-
head = bracket_split_build_line(head_leaves, line, opening_bracket)
643-
body = bracket_split_build_line(body_leaves, line, opening_bracket, is_body=True)
644-
tail = bracket_split_build_line(tail_leaves, line, opening_bracket)
660+
head = bracket_split_build_line(
661+
head_leaves, line, opening_bracket, component=_BracketSplitComponent.head
662+
)
663+
body = bracket_split_build_line(
664+
body_leaves, line, opening_bracket, component=_BracketSplitComponent.body
665+
)
666+
tail = bracket_split_build_line(
667+
tail_leaves, line, opening_bracket, component=_BracketSplitComponent.tail
668+
)
645669
bracket_split_succeeded_or_raise(head, body, tail)
646670
if (
647671
Feature.FORCE_OPTIONAL_PARENTHESES not in features
@@ -715,15 +739,23 @@ def bracket_split_succeeded_or_raise(head: Line, body: Line, tail: Line) -> None
715739

716740

717741
def bracket_split_build_line(
718-
leaves: List[Leaf], original: Line, opening_bracket: Leaf, *, is_body: bool = False
742+
leaves: List[Leaf],
743+
original: Line,
744+
opening_bracket: Leaf,
745+
*,
746+
component: _BracketSplitComponent,
719747
) -> Line:
720748
"""Return a new line with given `leaves` and respective comments from `original`.
721749
722-
If `is_body` is True, the result line is one-indented inside brackets and as such
723-
has its first leaf's prefix normalized and a trailing comma added when expected.
750+
If it's the head component, brackets will be tracked so trailing commas are
751+
respected.
752+
753+
If it's the body component, the result line is one-indented inside brackets and as
754+
such has its first leaf's prefix normalized and a trailing comma added when
755+
expected.
724756
"""
725757
result = Line(mode=original.mode, depth=original.depth)
726-
if is_body:
758+
if component is _BracketSplitComponent.body:
727759
result.inside_brackets = True
728760
result.depth += 1
729761
if leaves:
@@ -761,12 +793,24 @@ def bracket_split_build_line(
761793
leaves.insert(i + 1, new_comma)
762794
break
763795

796+
leaves_to_track: Set[LeafID] = set()
797+
if (
798+
Preview.handle_trailing_commas_in_head in original.mode
799+
and component is _BracketSplitComponent.head
800+
):
801+
leaves_to_track = get_leaves_inside_matching_brackets(leaves)
764802
# Populate the line
765803
for leaf in leaves:
766-
result.append(leaf, preformatted=True)
804+
result.append(
805+
leaf,
806+
preformatted=True,
807+
track_bracket=id(leaf) in leaves_to_track,
808+
)
767809
for comment_after in original.comments_after(leaf):
768810
result.append(comment_after, preformatted=True)
769-
if is_body and should_split_line(result, opening_bracket):
811+
if component is _BracketSplitComponent.body and should_split_line(
812+
result, opening_bracket
813+
):
770814
result.should_split_rhs = True
771815
return result
772816

src/black/lines.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ class Line:
5353
should_split_rhs: bool = False
5454
magic_trailing_comma: Optional[Leaf] = None
5555

56-
def append(self, leaf: Leaf, preformatted: bool = False) -> None:
56+
def append(
57+
self, leaf: Leaf, preformatted: bool = False, track_bracket: bool = False
58+
) -> None:
5759
"""Add a new `leaf` to the end of the line.
5860
5961
Unless `preformatted` is True, the `leaf` will receive a new consistent
@@ -75,7 +77,7 @@ def append(self, leaf: Leaf, preformatted: bool = False) -> None:
7577
leaf.prefix += whitespace(
7678
leaf, complex_subscript=self.is_complex_subscript(leaf)
7779
)
78-
if self.inside_brackets or not preformatted:
80+
if self.inside_brackets or not preformatted or track_bracket:
7981
self.bracket_tracker.mark(leaf)
8082
if self.mode.magic_trailing_comma:
8183
if self.has_magic_trailing_comma(leaf):

src/black/mode.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ class Preview(Enum):
151151

152152
annotation_parens = auto()
153153
empty_lines_before_class_or_def_with_leading_comments = auto()
154+
handle_trailing_commas_in_head = auto()
154155
long_docstring_quotes_on_newline = auto()
155156
normalize_docstring_quotes_and_prefixes_properly = auto()
156157
one_element_subscript = auto()

tests/data/preview/skip_magic_trailing_comma.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,37 @@
1515
# Except single element tuples
1616
small_tuple = (1,)
1717

18+
# Trailing commas in multiple chained non-nested parens.
19+
zero(
20+
one,
21+
).two(
22+
three,
23+
).four(
24+
five,
25+
)
26+
27+
func1(arg1).func2(arg2,).func3(arg3).func4(arg4,).func5(arg5)
28+
29+
(
30+
a,
31+
b,
32+
c,
33+
d,
34+
) = func1(
35+
arg1
36+
) and func2(arg2)
37+
38+
func(
39+
argument1,
40+
(
41+
one,
42+
two,
43+
),
44+
argument4,
45+
argument5,
46+
argument6,
47+
)
48+
1849
# output
1950
# We should not remove the trailing comma in a single-element subscript.
2051
a: tuple[int,]
@@ -32,3 +63,12 @@
3263

3364
# Except single element tuples
3465
small_tuple = (1,)
66+
67+
# Trailing commas in multiple chained non-nested parens.
68+
zero(one).two(three).four(five)
69+
70+
func1(arg1).func2(arg2).func3(arg3).func4(arg4).func5(arg5)
71+
72+
(a, b, c, d) = func1(arg1) and func2(arg2)
73+
74+
func(argument1, (one, two), argument4, argument5, argument6)
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
zero(one,).two(three,).four(five,)
2+
3+
func1(arg1).func2(arg2,).func3(arg3).func4(arg4,).func5(arg5)
4+
5+
# Inner one-element tuple shouldn't explode
6+
func1(arg1).func2(arg1, (one_tuple,)).func3(arg3)
7+
8+
(a, b, c, d,) = func1(arg1) and func2(arg2)
9+
10+
11+
# Example from https://github.com/psf/black/issues/3229
12+
def refresh_token(self, device_family, refresh_token, api_key):
13+
return self.orchestration.refresh_token(
14+
data={
15+
"refreshToken": refresh_token,
16+
},
17+
api_key=api_key,
18+
)["extensions"]["sdk"]["token"]
19+
20+
21+
# Edge case where a bug in a working-in-progress version of
22+
# https://github.com/psf/black/pull/3370 causes an infinite recursion.
23+
assert (
24+
long_module.long_class.long_func().another_func()
25+
== long_module.long_class.long_func()["some_key"].another_func(arg1)
26+
)
27+
28+
29+
# output
30+
31+
32+
zero(
33+
one,
34+
).two(
35+
three,
36+
).four(
37+
five,
38+
)
39+
40+
func1(arg1).func2(
41+
arg2,
42+
).func3(arg3).func4(
43+
arg4,
44+
).func5(arg5)
45+
46+
# Inner one-element tuple shouldn't explode
47+
func1(arg1).func2(arg1, (one_tuple,)).func3(arg3)
48+
49+
(
50+
a,
51+
b,
52+
c,
53+
d,
54+
) = func1(
55+
arg1
56+
) and func2(arg2)
57+
58+
59+
# Example from https://github.com/psf/black/issues/3229
60+
def refresh_token(self, device_family, refresh_token, api_key):
61+
return self.orchestration.refresh_token(
62+
data={
63+
"refreshToken": refresh_token,
64+
},
65+
api_key=api_key,
66+
)["extensions"]["sdk"]["token"]
67+
68+
69+
# Edge case where a bug in a working-in-progress version of
70+
# https://github.com/psf/black/pull/3370 causes an infinite recursion.
71+
assert (
72+
long_module.long_class.long_func().another_func()
73+
== long_module.long_class.long_func()["some_key"].another_func(arg1)
74+
)

tests/data/simple_cases/function_trailing_comma.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,17 @@ def func() -> ((also_super_long_type_annotation_that_may_cause_an_AST_related_cr
4949
):
5050
pass
5151

52+
53+
# Make sure inner one-element tuple won't explode
54+
some_module.some_function(
55+
argument1, (one_element_tuple,), argument4, argument5, argument6
56+
)
57+
58+
# Inner trailing comma causes outer to explode
59+
some_module.some_function(
60+
argument1, (one, two,), argument4, argument5, argument6
61+
)
62+
5263
# output
5364

5465
def f(
@@ -151,3 +162,21 @@ def func() -> (
151162
)
152163
):
153164
pass
165+
166+
167+
# Make sure inner one-element tuple won't explode
168+
some_module.some_function(
169+
argument1, (one_element_tuple,), argument4, argument5, argument6
170+
)
171+
172+
# Inner trailing comma causes outer to explode
173+
some_module.some_function(
174+
argument1,
175+
(
176+
one,
177+
two,
178+
),
179+
argument4,
180+
argument5,
181+
argument6,
182+
)

0 commit comments

Comments
 (0)