Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@

<!-- Changes that affect Black's preview style -->

- Format long concatenations of two lists symmetrically when both operands fit on their
own delimiter-split line (#5259)
- Preserve two blank lines before a top-level class starting inside a `# fmt: off` block
after an import (#5238)
- Fix unnecessary parentheses around short RHS expressions in indexed assignments like
Expand Down
28 changes: 28 additions & 0 deletions docs/the_black_code_style/future_style.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,34 @@ Currently, the following features are included in the preview style:
statements.
- `fmt_off_class_blank_lines`: Preserve two blank lines before a top-level class whose
definition starts inside a `# fmt: off` block after an import.
- `symmetric_list_concatenation`: Keep optional parentheses around long concatenations
of two list displays when both operands fit on their own delimiter-split line.

(labels/symmetric-list-concatenation)=

### Symmetric list concatenation

When a concatenation of two list displays is too long for one line and both operands fit
on their own delimiter-split line, Black keeps optional parentheses around the
expression so that the operands can be split symmetrically. If either list needs an
internal split, including when an active magic trailing comma forces it onto multiple
lines, Black retains the existing formatting.

```python
# Before
names = ["Alice", "Bob", "Charlie", "Diana", "Edward"] + [
"Fiona",
"George",
"Harriet",
"Isabelle",
]

# After (with --preview)
names = (
["Alice", "Bob", "Charlie", "Diana", "Edward"]
+ ["Fiona", "George", "Harriet", "Isabelle"]
)
```

(labels/wrap-comprehension-in)=

Expand Down
11 changes: 11 additions & 0 deletions src/black/linegen.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
can_be_split,
can_omit_invisible_parens,
is_line_short_enough,
is_symmetric_list_concatenation,
line_to_string,
)
from black.mode import Feature, Mode, Preview
Expand Down Expand Up @@ -1089,6 +1090,14 @@ def _maybe_split_omitting_optional_parens(
features: Collection[Feature] = (),
omit: Collection[LeafID] = (),
) -> Iterator[Line]:
split_symmetric_lists = (
Preview.symmetric_list_concatenation in mode
and rhs.opening_bracket.type == token.LPAR
and not rhs.opening_bracket.value
and rhs.closing_bracket.type == token.RPAR
and not rhs.closing_bracket.value
and is_symmetric_list_concatenation(rhs.body, mode.line_length)
)
if (
Feature.FORCE_OPTIONAL_PARENTHESES not in features
# the opening bracket is an optional paren
Expand Down Expand Up @@ -1150,6 +1159,8 @@ def _maybe_split_omitting_optional_parens(

ensure_visible(rhs.opening_bracket)
ensure_visible(rhs.closing_bracket)
if split_symmetric_lists:
rhs.body.should_split_rhs = True
for result in (rhs.head, rhs.body, rhs.tail):
if result:
yield result
Expand Down
72 changes: 72 additions & 0 deletions src/black/lines.py
Original file line number Diff line number Diff line change
Expand Up @@ -1509,6 +1509,71 @@ def _is_annotated_assignment(head: Line) -> bool:
return False


def is_symmetric_list_concatenation(line: Line, line_length: int) -> bool:
"""Is `line` exactly two single-line lists joined by a top-level `+`?"""
if len(line.bracket_tracker.delimiters) != 1:
return False

# Delimiters is keyed by leaf ID, not line position.
delimiter_id = next(iter(line.bracket_tracker.delimiters))
Comment thread
ColumbusLabs marked this conversation as resolved.
try:
left_closing_index = next(
index for index, leaf in enumerate(line.leaves) if id(leaf) == delimiter_id
)
except StopIteration:
return False

# Math operators are split *before* the delimiter, so BracketTracker keys
# them by the preceding leaf.
delimiter_index = left_closing_index + 1
if delimiter_index >= len(line.leaves) - 1:
return False

first = line.leaves[0]
left_closing = line.leaves[left_closing_index]
delimiter = line.leaves[delimiter_index]
right_opening = line.leaves[delimiter_index + 1]
last = line.leaves[-1]

if not (
delimiter.type == token.PLUS
and first.type == token.LSQB
and left_closing.type == token.RSQB
and left_closing.opening_bracket is first
and right_opening.type == token.LSQB
and last.type == token.RSQB
and last.opening_bracket is right_opening
):
return False

# Keep the existing asymmetric split when either list is already forced to
# split by a magic trailing comma.
if line.mode.magic_trailing_comma and (
line.leaves[left_closing_index - 1].type == token.COMMA
or line.leaves[-2].type == token.COMMA
):
return False

def rendered_width(start: int, end: int) -> int | None:
leaves = line.leaves[start:end]
rendered = " " * line.depth
# End-of-line comments do not determine whether the operand itself fits.
for index, leaf in enumerate(leaves):
rendered += leaf.value if index == 0 else str(leaf)
if "\n" in rendered:
return None
return str_width(rendered)

left_width = rendered_width(0, delimiter_index)
right_width = rendered_width(delimiter_index, len(line.leaves))
return (
left_width is not None
and right_width is not None
and left_width <= line_length
and right_width <= line_length
)


def can_omit_invisible_parens(
rhs: RHSResult,
line_length: int,
Expand Down Expand Up @@ -1634,6 +1699,13 @@ def can_omit_invisible_parens(
# the with statement. `rhs.head` is the `with (` part on the previous
# line.
return False
if (
Preview.symmetric_list_concatenation in mode
and is_symmetric_list_concatenation(line, line_length)
):
# Retaining the optional parentheses lets the delimiter splitter put
# each list operand on its own line instead of exploding just one list.
return False
# Otherwise it may also read better, but we don't do it today and requires
# careful considerations for all possible cases. See
# https://github.com/psf/black/issues/2156.
Expand Down
1 change: 1 addition & 0 deletions src/black/mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ class Preview(Enum):
hug_comparator = auto()
parenthesize_tuple_in_yield = auto()
fmt_off_class_blank_lines = auto()
symmetric_list_concatenation = auto()


UNSTABLE_FEATURES: set[Preview] = {
Expand Down
3 changes: 2 additions & 1 deletion src/black/resources/black.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@
"pyi_blank_line_after_function_docstring",
"hug_comparator",
"parenthesize_tuple_in_yield",
"fmt_off_class_blank_lines"
"fmt_off_class_blank_lines",
"symmetric_list_concatenation"
]
},
"description": "Enable specific features included in the `--unstable` style. Requires `--preview`. No compatibility guarantees are provided on the behavior or existence of any unstable features."
Expand Down
138 changes: 138 additions & 0 deletions tests/data/cases/preview_symmetric_list_concatenation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# flags: --preview

# Regression test for https://github.com/psf/black/issues/260.
search_fields = (["file__%s" % field for field in FileAdmin.search_fields] + ["resource__%s" % field for field in ResourceAdmin.search_fields])

# Plain list displays receive the same symmetric treatment.
names = ["Alice", "Bob", "Charlie", "Diana", "Edward"] + ["Fiona", "George", "Harriet", "Isabelle"]

# Comments on an operand stay attached and formatting remains stable.
commented = (
["first_long_value", "second_long_value", "third_long_value"] # first list
+ ["fourth_long_value", "fifth_long_value", "sixth_long_value"]
)

commented_left = (
["first_value", "second_value", "third_value"] # abc
+ ["fourth_value", "fifth_value", "sixth_value"]
)
commented_right = (
["first_value", "second_value", "third_value"]
+ ["fourth_value", "fifth_value", "sixth_value"] # abc
)

# Split symmetrically even when the RHS alone fits inside optional parentheses.
values = [first_value, second_value, third_value] + [fourth_value, fifth_value, sixth_value]

# Chained concatenations already use the normal delimiter split.
chained = ["first_long_value", "second_long_value"] + ["third_long_value", "fourth_long_value"] + ["fifth_long_value", "sixth_long_value"]

# Mixed operands are not symmetric list concatenations.
mixed_left = ["first_long_value", "second_long_value", "third_long_value"] + tuple_with_a_very_long_name
mixed_right = list_with_a_very_long_name + ["first_long_value", "second_long_value", "third_long_value"]

# Short concatenations stay on one line.
small = [1, 2] + [3, 4]

# Lists that already require bracket splitting keep the existing formatting.
long_left = ["first_value_with_an_extremely_long_name", "second_value_with_an_extremely_long_name", "third"] + ["short"]
long_right = ["short"] + ["first_value_with_an_extremely_long_name", "second_value_with_an_extremely_long_name", "third"]
both_long = ["first_value_with_an_extremely_long_name", "second_value_with_an_extremely_long_name", "third"] + ["fourth_value_with_an_extremely_long_name", "fifth_value_with_an_extremely_long_name", "sixth"]

# Magic trailing commas also keep the existing formatting.
magic_left = [
"first_long_value",
"second_long_value",
] + ["third_long_value", "fourth_long_value"]
magic_right = ["first_long_value", "second_long_value"] + [
"third_long_value",
"fourth_long_value",
]

# output

# Regression test for https://github.com/psf/black/issues/260.
search_fields = (
["file__%s" % field for field in FileAdmin.search_fields]
+ ["resource__%s" % field for field in ResourceAdmin.search_fields]
)

# Plain list displays receive the same symmetric treatment.
names = (
["Alice", "Bob", "Charlie", "Diana", "Edward"]
+ ["Fiona", "George", "Harriet", "Isabelle"]
)

# Comments on an operand stay attached and formatting remains stable.
commented = (
["first_long_value", "second_long_value", "third_long_value"] # first list
+ ["fourth_long_value", "fifth_long_value", "sixth_long_value"]
)

commented_left = (
["first_value", "second_value", "third_value"] # abc
+ ["fourth_value", "fifth_value", "sixth_value"]
)
commented_right = (
["first_value", "second_value", "third_value"]
+ ["fourth_value", "fifth_value", "sixth_value"] # abc
)

# Split symmetrically even when the RHS alone fits inside optional parentheses.
values = (
[first_value, second_value, third_value]
+ [fourth_value, fifth_value, sixth_value]
)

# Chained concatenations already use the normal delimiter split.
chained = (
["first_long_value", "second_long_value"]
+ ["third_long_value", "fourth_long_value"]
+ ["fifth_long_value", "sixth_long_value"]
)

# Mixed operands are not symmetric list concatenations.
mixed_left = [
"first_long_value",
"second_long_value",
"third_long_value",
] + tuple_with_a_very_long_name
mixed_right = list_with_a_very_long_name + [
"first_long_value",
"second_long_value",
"third_long_value",
]

# Short concatenations stay on one line.
small = [1, 2] + [3, 4]

# Lists that already require bracket splitting keep the existing formatting.
long_left = [
"first_value_with_an_extremely_long_name",
"second_value_with_an_extremely_long_name",
"third",
] + ["short"]
long_right = ["short"] + [
"first_value_with_an_extremely_long_name",
"second_value_with_an_extremely_long_name",
"third",
]
both_long = [
"first_value_with_an_extremely_long_name",
"second_value_with_an_extremely_long_name",
"third",
] + [
"fourth_value_with_an_extremely_long_name",
"fifth_value_with_an_extremely_long_name",
"sixth",
]

# Magic trailing commas also keep the existing formatting.
magic_left = [
"first_long_value",
"second_long_value",
] + ["third_long_value", "fourth_long_value"]
magic_right = ["first_long_value", "second_long_value"] + [
"third_long_value",
"fourth_long_value",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# flags: --preview --line-length=60

values = [first_value, second_value] + [third_value, fourth_value]
short_values = [first_value] + [second_value]

# output

values = (
[first_value, second_value]
+ [third_value, fourth_value]
)
short_values = [first_value] + [second_value]
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# flags: --preview --skip-magic-trailing-comma

# Ignored trailing commas do not prevent symmetric formatting.
values = [
"first_long_value",
"second_long_value",
] + ["third_long_value", "fourth_long_value"]

# output

# Ignored trailing commas do not prevent symmetric formatting.
values = (
["first_long_value", "second_long_value"]
+ ["third_long_value", "fourth_long_value"]
)
4 changes: 4 additions & 0 deletions tests/data/cases/stable_symmetric_list_concatenation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Stable style must keep the existing asymmetric bracket split.
search_fields = ["file__%s" % field for field in FileAdmin.search_fields] + [
"resource__%s" % field for field in ResourceAdmin.search_fields
]
Loading