Skip to content

Commit ba618a3

Browse files
authored
Add parens around implicit string concatenations where it increases readability (#3162)
Adds parentheses around implicit string concatenations when it's inside a list, set, or tuple. Except when it's only element and there's no trailing comma. Looking at the order of the transformers here, we need to "wrap in parens" before string_split runs. So my solution is to introduce a "collaboration" between StringSplitter and StringParenWrapper where the splitter "skips" the split until the wrapper adds the parens (and then the line after the paren is split by StringSplitter) in another pass. I have also considered an alternative approach, where I tried to add a different "string paren wrapper" class, and it runs before string_split. Then I found out it requires a different do_transform implementation than StringParenWrapper.do_transform, since the later assumes it runs after the delimiter_split transform. So I stopped researching that route. Originally function calls were also included in this change, but given missing commas should usually result in a runtime error and the scary amount of changes this cause on downstream code, they were removed in later revisions.
1 parent c0cc19b commit ba618a3

File tree

6 files changed

+191
-44
lines changed

6 files changed

+191
-44
lines changed

CHANGES.md

+2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
normalized as expected (#3168)
2727
- When using `--skip-magic-trailing-comma` or `-C`, trailing commas are stripped from
2828
subscript expressions with more than 1 element (#3209)
29+
- Implicitly concatenated strings inside a list, set, or tuple are now wrapped inside
30+
parentheses (#3162)
2931
- Fix a string merging/split issue when a comment is present in the middle of implicitly
3032
concatenated strings on its own line (#3227)
3133

src/black/trans.py

+48-2
Original file line numberDiff line numberDiff line change
@@ -1043,6 +1043,41 @@ def _get_max_string_length(self, line: Line, string_idx: int) -> int:
10431043
max_string_length = self.line_length - offset
10441044
return max_string_length
10451045

1046+
@staticmethod
1047+
def _prefer_paren_wrap_match(LL: List[Leaf]) -> Optional[int]:
1048+
"""
1049+
Returns:
1050+
string_idx such that @LL[string_idx] is equal to our target (i.e.
1051+
matched) string, if this line matches the "prefer paren wrap" statement
1052+
requirements listed in the 'Requirements' section of the StringParenWrapper
1053+
class's docstring.
1054+
OR
1055+
None, otherwise.
1056+
"""
1057+
# The line must start with a string.
1058+
if LL[0].type != token.STRING:
1059+
return None
1060+
1061+
matching_nodes = [
1062+
syms.listmaker,
1063+
syms.dictsetmaker,
1064+
syms.testlist_gexp,
1065+
]
1066+
# If the string is an immediate child of a list/set/tuple literal...
1067+
if (
1068+
parent_type(LL[0]) in matching_nodes
1069+
or parent_type(LL[0].parent) in matching_nodes
1070+
):
1071+
# And the string is surrounded by commas (or is the first/last child)...
1072+
prev_sibling = LL[0].prev_sibling
1073+
next_sibling = LL[0].next_sibling
1074+
if (not prev_sibling or prev_sibling.type == token.COMMA) and (
1075+
not next_sibling or next_sibling.type == token.COMMA
1076+
):
1077+
return 0
1078+
1079+
return None
1080+
10461081

10471082
def iter_fexpr_spans(s: str) -> Iterator[Tuple[int, int]]:
10481083
"""
@@ -1138,6 +1173,9 @@ class StringSplitter(BaseStringSplitter, CustomSplitMapMixin):
11381173
def do_splitter_match(self, line: Line) -> TMatchResult:
11391174
LL = line.leaves
11401175

1176+
if self._prefer_paren_wrap_match(LL) is not None:
1177+
return TErr("Line needs to be wrapped in parens first.")
1178+
11411179
is_valid_index = is_valid_index_factory(LL)
11421180

11431181
idx = 0
@@ -1583,8 +1621,7 @@ def _get_string_operator_leaves(self, leaves: Iterable[Leaf]) -> List[Leaf]:
15831621

15841622
class StringParenWrapper(BaseStringSplitter, CustomSplitMapMixin):
15851623
"""
1586-
StringTransformer that splits non-"atom" strings (i.e. strings that do not
1587-
exist on lines by themselves).
1624+
StringTransformer that wraps strings in parens and then splits at the LPAR.
15881625
15891626
Requirements:
15901627
All of the requirements listed in BaseStringSplitter's docstring in
@@ -1604,6 +1641,11 @@ class StringParenWrapper(BaseStringSplitter, CustomSplitMapMixin):
16041641
OR
16051642
* The line is a dictionary key assignment where some valid key is being
16061643
assigned the value of some string.
1644+
OR
1645+
* The line starts with an "atom" string that prefers to be wrapped in
1646+
parens. It's preferred to be wrapped when it's is an immediate child of
1647+
a list/set/tuple literal, AND the string is surrounded by commas (or is
1648+
the first/last child).
16071649
16081650
Transformations:
16091651
The chosen string is wrapped in parentheses and then split at the LPAR.
@@ -1628,6 +1670,9 @@ class StringParenWrapper(BaseStringSplitter, CustomSplitMapMixin):
16281670
changed such that it no longer needs to be given its own line,
16291671
StringParenWrapper relies on StringParenStripper to clean up the
16301672
parentheses it created.
1673+
1674+
For "atom" strings that prefers to be wrapped in parens, it requires
1675+
StringSplitter to hold the split until the string is wrapped in parens.
16311676
"""
16321677

16331678
def do_splitter_match(self, line: Line) -> TMatchResult:
@@ -1644,6 +1689,7 @@ def do_splitter_match(self, line: Line) -> TMatchResult:
16441689
or self._assert_match(LL)
16451690
or self._assign_match(LL)
16461691
or self._dict_match(LL)
1692+
or self._prefer_paren_wrap_match(LL)
16471693
)
16481694

16491695
if string_idx is not None:

tests/data/preview/comments7.py

+30-16
Original file line numberDiff line numberDiff line change
@@ -226,39 +226,53 @@ class C:
226226
# metadata_version errors.
227227
(
228228
{},
229-
"None is an invalid value for Metadata-Version. Error: This field is"
230-
" required. see"
231-
" https://packaging.python.org/specifications/core-metadata",
229+
(
230+
"None is an invalid value for Metadata-Version. Error: This field"
231+
" is required. see"
232+
" https://packaging.python.org/specifications/core-metadata"
233+
),
232234
),
233235
(
234236
{"metadata_version": "-1"},
235-
"'-1' is an invalid value for Metadata-Version. Error: Unknown Metadata"
236-
" Version see"
237-
" https://packaging.python.org/specifications/core-metadata",
237+
(
238+
"'-1' is an invalid value for Metadata-Version. Error: Unknown"
239+
" Metadata Version see"
240+
" https://packaging.python.org/specifications/core-metadata"
241+
),
238242
),
239243
# name errors.
240244
(
241245
{"metadata_version": "1.2"},
242-
"'' is an invalid value for Name. Error: This field is required. see"
243-
" https://packaging.python.org/specifications/core-metadata",
246+
(
247+
"'' is an invalid value for Name. Error: This field is required."
248+
" see https://packaging.python.org/specifications/core-metadata"
249+
),
244250
),
245251
(
246252
{"metadata_version": "1.2", "name": "foo-"},
247-
"'foo-' is an invalid value for Name. Error: Must start and end with a"
248-
" letter or numeral and contain only ascii numeric and '.', '_' and"
249-
" '-'. see https://packaging.python.org/specifications/core-metadata",
253+
(
254+
"'foo-' is an invalid value for Name. Error: Must start and end"
255+
" with a letter or numeral and contain only ascii numeric and '.',"
256+
" '_' and '-'. see"
257+
" https://packaging.python.org/specifications/core-metadata"
258+
),
250259
),
251260
# version errors.
252261
(
253262
{"metadata_version": "1.2", "name": "example"},
254-
"'' is an invalid value for Version. Error: This field is required. see"
255-
" https://packaging.python.org/specifications/core-metadata",
263+
(
264+
"'' is an invalid value for Version. Error: This field is required."
265+
" see https://packaging.python.org/specifications/core-metadata"
266+
),
256267
),
257268
(
258269
{"metadata_version": "1.2", "name": "example", "version": "dog"},
259-
"'dog' is an invalid value for Version. Error: Must start and end with"
260-
" a letter or numeral and contain only ascii numeric and '.', '_' and"
261-
" '-'. see https://packaging.python.org/specifications/core-metadata",
270+
(
271+
"'dog' is an invalid value for Version. Error: Must start and end"
272+
" with a letter or numeral and contain only ascii numeric and '.',"
273+
" '_' and '-'. see"
274+
" https://packaging.python.org/specifications/core-metadata"
275+
),
262276
),
263277
],
264278
)

tests/data/preview/long_strings.py

+79-2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@
1818

1919
D4 = {"A long and ridiculous {}".format(string_key): "This is a really really really long string that has to go i,side of a dictionary. It is soooo bad.", some_func("calling", "some", "stuff"): "This is a really really really long string that has to go inside of a dictionary. It is {soooo} bad (#{x}).".format(sooo="soooo", x=2), "A %s %s" % ("formatted", "string"): "This is a really really really long string that has to go inside of a dictionary. It is %s bad (#%d)." % ("soooo", 2)}
2020

21+
L1 = ["The is a short string", "This is a really long string that can't possibly be expected to fit all together on one line. Also it is inside a list literal, so it's expected to be wrapped in parens when spliting to avoid implicit str concatenation.", short_call("arg", {"key": "value"}), "This is another really really (not really) long string that also can't be expected to fit on one line and is, like the other string, inside a list literal.", ("parens should be stripped for short string in list")]
22+
23+
L2 = ["This is a really long string that can't be expected to fit in one line and is the only child of a list literal."]
24+
25+
S1 = {"The is a short string", "This is a really long string that can't possibly be expected to fit all together on one line. Also it is inside a set literal, so it's expected to be wrapped in parens when spliting to avoid implicit str concatenation.", short_call("arg", {"key": "value"}), "This is another really really (not really) long string that also can't be expected to fit on one line and is, like the other string, inside a set literal.", ("parens should be stripped for short string in set")}
26+
27+
S2 = {"This is a really long string that can't be expected to fit in one line and is the only child of a set literal."}
28+
29+
T1 = ("The is a short string", "This is a really long string that can't possibly be expected to fit all together on one line. Also it is inside a tuple literal, so it's expected to be wrapped in parens when spliting to avoid implicit str concatenation.", short_call("arg", {"key": "value"}), "This is another really really (not really) long string that also can't be expected to fit on one line and is, like the other string, inside a tuple literal.", ("parens should be stripped for short string in list"))
30+
31+
T2 = ("This is a really long string that can't be expected to fit in one line and is the only child of a tuple literal.",)
32+
2133
func_with_keywords(my_arg, my_kwarg="Long keyword strings also need to be wrapped, but they will probably need to be handled a little bit differently.")
2234

2335
bad_split1 = (
@@ -109,7 +121,7 @@
109121

110122
comment_string = "Long lines with inline comments should have their comments appended to the reformatted string's enclosing right parentheses." # This comment gets thrown to the top.
111123

112-
arg_comment_string = print("Long lines with inline comments which are apart of (and not the only member of) an argument list should have their comments appended to the reformatted string's enclosing left parentheses.", # This comment stays on the bottom.
124+
arg_comment_string = print("Long lines with inline comments which are apart of (and not the only member of) an argument list should have their comments appended to the reformatted string's enclosing left parentheses.", # This comment gets thrown to the top.
113125
"Arg #2", "Arg #3", "Arg #4", "Arg #5")
114126

115127
pragma_comment_string1 = "Lines which end with an inline pragma comment of the form `# <pragma>: <...>` should be left alone." # noqa: E501
@@ -345,6 +357,71 @@ def foo():
345357
% ("soooo", 2),
346358
}
347359

360+
L1 = [
361+
"The is a short string",
362+
(
363+
"This is a really long string that can't possibly be expected to fit all"
364+
" together on one line. Also it is inside a list literal, so it's expected to"
365+
" be wrapped in parens when spliting to avoid implicit str concatenation."
366+
),
367+
short_call("arg", {"key": "value"}),
368+
(
369+
"This is another really really (not really) long string that also can't be"
370+
" expected to fit on one line and is, like the other string, inside a list"
371+
" literal."
372+
),
373+
"parens should be stripped for short string in list",
374+
]
375+
376+
L2 = [
377+
"This is a really long string that can't be expected to fit in one line and is the"
378+
" only child of a list literal."
379+
]
380+
381+
S1 = {
382+
"The is a short string",
383+
(
384+
"This is a really long string that can't possibly be expected to fit all"
385+
" together on one line. Also it is inside a set literal, so it's expected to be"
386+
" wrapped in parens when spliting to avoid implicit str concatenation."
387+
),
388+
short_call("arg", {"key": "value"}),
389+
(
390+
"This is another really really (not really) long string that also can't be"
391+
" expected to fit on one line and is, like the other string, inside a set"
392+
" literal."
393+
),
394+
"parens should be stripped for short string in set",
395+
}
396+
397+
S2 = {
398+
"This is a really long string that can't be expected to fit in one line and is the"
399+
" only child of a set literal."
400+
}
401+
402+
T1 = (
403+
"The is a short string",
404+
(
405+
"This is a really long string that can't possibly be expected to fit all"
406+
" together on one line. Also it is inside a tuple literal, so it's expected to"
407+
" be wrapped in parens when spliting to avoid implicit str concatenation."
408+
),
409+
short_call("arg", {"key": "value"}),
410+
(
411+
"This is another really really (not really) long string that also can't be"
412+
" expected to fit on one line and is, like the other string, inside a tuple"
413+
" literal."
414+
),
415+
"parens should be stripped for short string in list",
416+
)
417+
418+
T2 = (
419+
(
420+
"This is a really long string that can't be expected to fit in one line and is"
421+
" the only child of a tuple literal."
422+
),
423+
)
424+
348425
func_with_keywords(
349426
my_arg,
350427
my_kwarg=(
@@ -487,7 +564,7 @@ def foo():
487564
arg_comment_string = print(
488565
"Long lines with inline comments which are apart of (and not the only member of) an"
489566
" argument list should have their comments appended to the reformatted string's"
490-
" enclosing left parentheses.", # This comment stays on the bottom.
567+
" enclosing left parentheses.", # This comment gets thrown to the top.
491568
"Arg #2",
492569
"Arg #3",
493570
"Arg #4",

tests/data/preview/long_strings__regression.py

+20-12
Original file line numberDiff line numberDiff line change
@@ -763,20 +763,28 @@ def xxxx_xxx_xx_xxxxxxxxxx_xxxx_xxxxxxxxx(xxxx):
763763

764764
some_dictionary = {
765765
"xxxxx006": [
766-
"xxx-xxx"
767-
" xxxxx3xxxx1xx2xxxxxxxxxxxxxx0xx6xxxxxxxxxx2xxxxxx9xxxxxxxxxx0xxxxx1xxx2x/xx9xx6+x+xxxxxxxxxxxxxx4xxxxxxxxxxxxxxxxxxxxx43xxx2xx2x4x++xxx6xxxxxxxxx+xxxxx/xx9x+xxxxxxxxxxxxxx8x15xxxxxxxxxxxxxxxxx82xx/xxxxxxxxxxxxxx/x5xxxxxxxxxxxxxx6xxxxxx74x4/xxx4x+xxxxxxxxx2xxxxxxxx87xxxxx4xxxxxxxx3xx0xxxxx4xxx1xx9xx5xxxxxxx/xxxxx5xx6xx4xxxx1x/x2xxxxxxxxxxxx64xxxxxxx1x0xx5xxxxxxxxxxxxxx=="
768-
" xxxxx000 xxxxxxxxxx\n",
769-
"xxx-xxx"
770-
" xxxxx3xxxx1xx2xxxxxxxxxxxxxx6xxxxxxxxxxxxxx9xxxxxxxxxxxxx3xxx9xxxxxxxxxxxxxxxx0xxxxxxxxxxxxxxxxx2xxxx2xxx6xxxxx/xx54xxxxxxxxx4xxx3xxxxxx9xx3xxxxx39xxxxxxxxx5xx91xxxx7xxxxxx8xxxxxxxxxxxxxxxx9xxx93xxxxxxxxxxxxxxxxx7xxx8xx8xx4/x1xxxxx1x3xxxxxxxxxxxxx3xxxxxx9xx4xx4x7xxxxxxxxxxxxx1xxxxxxxxx7xxxxxxxxxxxxxx4xx6xxxxxxxxx9xxx7xxxx2xxxxxxxxxxxxxxxxxxxxxx8xxxxxxxxxxxxxxxxxxxx6xx=="
771-
" xxxxx010 xxxxxxxxxx\n",
766+
(
767+
"xxx-xxx"
768+
" xxxxx3xxxx1xx2xxxxxxxxxxxxxx0xx6xxxxxxxxxx2xxxxxx9xxxxxxxxxx0xxxxx1xxx2x/xx9xx6+x+xxxxxxxxxxxxxx4xxxxxxxxxxxxxxxxxxxxx43xxx2xx2x4x++xxx6xxxxxxxxx+xxxxx/xx9x+xxxxxxxxxxxxxx8x15xxxxxxxxxxxxxxxxx82xx/xxxxxxxxxxxxxx/x5xxxxxxxxxxxxxx6xxxxxx74x4/xxx4x+xxxxxxxxx2xxxxxxxx87xxxxx4xxxxxxxx3xx0xxxxx4xxx1xx9xx5xxxxxxx/xxxxx5xx6xx4xxxx1x/x2xxxxxxxxxxxx64xxxxxxx1x0xx5xxxxxxxxxxxxxx=="
769+
" xxxxx000 xxxxxxxxxx\n"
770+
),
771+
(
772+
"xxx-xxx"
773+
" xxxxx3xxxx1xx2xxxxxxxxxxxxxx6xxxxxxxxxxxxxx9xxxxxxxxxxxxx3xxx9xxxxxxxxxxxxxxxx0xxxxxxxxxxxxxxxxx2xxxx2xxx6xxxxx/xx54xxxxxxxxx4xxx3xxxxxx9xx3xxxxx39xxxxxxxxx5xx91xxxx7xxxxxx8xxxxxxxxxxxxxxxx9xxx93xxxxxxxxxxxxxxxxx7xxx8xx8xx4/x1xxxxx1x3xxxxxxxxxxxxx3xxxxxx9xx4xx4x7xxxxxxxxxxxxx1xxxxxxxxx7xxxxxxxxxxxxxx4xx6xxxxxxxxx9xxx7xxxx2xxxxxxxxxxxxxxxxxxxxxx8xxxxxxxxxxxxxxxxxxxx6xx=="
774+
" xxxxx010 xxxxxxxxxx\n"
775+
),
772776
],
773777
"xxxxx016": [
774-
"xxx-xxx"
775-
" xxxxx3xxxx1xx2xxxxxxxxxxxxxx0xx6xxxxxxxxxx2xxxxxx9xxxxxxxxxx0xxxxx1xxx2x/xx9xx6+x+xxxxxxxxxxxxxx4xxxxxxxxxxxxxxxxxxxxx43xxx2xx2x4x++xxx6xxxxxxxxx+xxxxx/xx9x+xxxxxxxxxxxxxx8x15xxxxxxxxxxxxxxxxx82xx/xxxxxxxxxxxxxx/x5xxxxxxxxxxxxxx6xxxxxx74x4/xxx4x+xxxxxxxxx2xxxxxxxx87xxxxx4xxxxxxxx3xx0xxxxx4xxx1xx9xx5xxxxxxx/xxxxx5xx6xx4xxxx1x/x2xxxxxxxxxxxx64xxxxxxx1x0xx5xxxxxxxxxxxxxx=="
776-
" xxxxx000 xxxxxxxxxx\n",
777-
"xxx-xxx"
778-
" xxxxx3xxxx1xx2xxxxxxxxxxxxxx6xxxxxxxxxxxxxx9xxxxxxxxxxxxx3xxx9xxxxxxxxxxxxxxxx0xxxxxxxxxxxxxxxxx2xxxx2xxx6xxxxx/xx54xxxxxxxxx4xxx3xxxxxx9xx3xxxxx39xxxxxxxxx5xx91xxxx7xxxxxx8xxxxxxxxxxxxxxxx9xxx93xxxxxxxxxxxxxxxxx7xxx8xx8xx4/x1xxxxx1x3xxxxxxxxxxxxx3xxxxxx9xx4xx4x7xxxxxxxxxxxxx1xxxxxxxxx7xxxxxxxxxxxxxx4xx6xxxxxxxxx9xxx7xxxx2xxxxxxxxxxxxxxxxxxxxxx8xxxxxxxxxxxxxxxxxxxx6xx=="
779-
" xxxxx010 xxxxxxxxxx\n",
778+
(
779+
"xxx-xxx"
780+
" xxxxx3xxxx1xx2xxxxxxxxxxxxxx0xx6xxxxxxxxxx2xxxxxx9xxxxxxxxxx0xxxxx1xxx2x/xx9xx6+x+xxxxxxxxxxxxxx4xxxxxxxxxxxxxxxxxxxxx43xxx2xx2x4x++xxx6xxxxxxxxx+xxxxx/xx9x+xxxxxxxxxxxxxx8x15xxxxxxxxxxxxxxxxx82xx/xxxxxxxxxxxxxx/x5xxxxxxxxxxxxxx6xxxxxx74x4/xxx4x+xxxxxxxxx2xxxxxxxx87xxxxx4xxxxxxxx3xx0xxxxx4xxx1xx9xx5xxxxxxx/xxxxx5xx6xx4xxxx1x/x2xxxxxxxxxxxx64xxxxxxx1x0xx5xxxxxxxxxxxxxx=="
781+
" xxxxx000 xxxxxxxxxx\n"
782+
),
783+
(
784+
"xxx-xxx"
785+
" xxxxx3xxxx1xx2xxxxxxxxxxxxxx6xxxxxxxxxxxxxx9xxxxxxxxxxxxx3xxx9xxxxxxxxxxxxxxxx0xxxxxxxxxxxxxxxxx2xxxx2xxx6xxxxx/xx54xxxxxxxxx4xxx3xxxxxx9xx3xxxxx39xxxxxxxxx5xx91xxxx7xxxxxx8xxxxxxxxxxxxxxxx9xxx93xxxxxxxxxxxxxxxxx7xxx8xx8xx4/x1xxxxx1x3xxxxxxxxxxxxx3xxxxxx9xx4xx4x7xxxxxxxxxxxxx1xxxxxxxxx7xxxxxxxxxxxxxx4xx6xxxxxxxxx9xxx7xxxx2xxxxxxxxxxxxxxxxxxxxxx8xxxxxxxxxxxxxxxxxxxx6xx=="
786+
" xxxxx010 xxxxxxxxxx\n"
787+
),
780788
],
781789
}
782790

0 commit comments

Comments
 (0)