diff --git a/CHANGES.md b/CHANGES.md index 76993ca3fe4..128d84b8ff3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -22,6 +22,8 @@ +- Remove unnecessary parentheses from the left-hand side of assignments while preserving + magic trailing commas and intentional multiline formatting (#4865) - Fix `fix_fmt_skip_in_one_liners` crashing on `with` statements (#4853) - Fix `fix_fmt_skip_in_one_liners` crashing on annotated parameters (#4854) diff --git a/docs/the_black_code_style/future_style.md b/docs/the_black_code_style/future_style.md index 181a218d1a8..07bc5258d92 100644 --- a/docs/the_black_code_style/future_style.md +++ b/docs/the_black_code_style/future_style.md @@ -31,6 +31,11 @@ Currently, the following features are included in the preview style: - `fix_module_docstring_detection`: Fix module docstrings being treated as normal strings if preceeded by comments. - `fix_type_expansion_split`: Fix type expansions split in generic functions. +- `remove_parens_from_assignment_lhs`: Remove unnecessary parentheses from the left-hand + side of assignments while preserving magic trailing commas and intentional multiline + formatting. For example, `(b) = a()[0]` becomes `b = a()[0]`, and `(c, *_) = a()` + becomes `c, *_ = a()`, but `(d,) = a()` is preserved as it defines a single-element + tuple. - `multiline_string_handling`: more compact formatting of expressions involving multiline strings ([see below](labels/multiline-string-handling)) - `fix_module_docstring_detection`: Fix module docstrings being treated as normal diff --git a/src/black/linegen.py b/src/black/linegen.py index 240a2f814e4..cbff4aa2cac 100644 --- a/src/black/linegen.py +++ b/src/black/linegen.py @@ -1524,6 +1524,26 @@ def normalize_invisible_parens( # noqa: C901 ): check_lpar = True + # Check for assignment LHS with preview feature enabled + if ( + Preview.remove_parens_from_assignment_lhs in mode + and index == 0 + and isinstance(child, Node) + and child.type == syms.atom + and node.type == syms.expr_stmt + and not _atom_has_magic_trailing_comma(child, mode) + and not _is_atom_multiline(child) + ): + if maybe_make_parens_invisible_in_atom( + child, + parent=node, + mode=mode, + features=features, + remove_brackets_around_comma=True, + allow_star_expr=True, + ): + wrap_in_parentheses(node, child, visible=False) + if check_lpar: if ( child.type == syms.atom @@ -1729,12 +1749,40 @@ def remove_with_parens( wrap_in_parentheses(node, node.children[0], visible=False) +def _atom_has_magic_trailing_comma(node: LN, mode: Mode) -> bool: + """Check if an atom node has a magic trailing comma. + + Returns True for single-element tuples with trailing commas like (a,), + which should be preserved to maintain their tuple type. + """ + if not mode.magic_trailing_comma: + return False + + return is_one_tuple(node) + + +def _is_atom_multiline(node: LN) -> bool: + """Check if an atom node is multiline (indicating intentional formatting).""" + if not isinstance(node, Node) or len(node.children) < 3: + return False + + # Check the middle child (between LPAR and RPAR) for newlines in its subtree + # The first child's prefix contains blank lines/comments before the opening paren + middle = node.children[1] + for child in middle.pre_order(): + if isinstance(child, Leaf) and "\n" in child.prefix: + return True + + return False + + def maybe_make_parens_invisible_in_atom( node: LN, parent: LN, mode: Mode, features: Collection[Feature], remove_brackets_around_comma: bool = False, + allow_star_expr: bool = False, ) -> bool: """If it's safe, make the parens in the atom `node` invisible, recursively. Additionally, remove repeated, adjacent invisible parens from the atom `node` @@ -1780,7 +1828,7 @@ def maybe_make_parens_invisible_in_atom( ) ) or is_tuple_containing_walrus(node) - or is_tuple_containing_star(node) + or (not allow_star_expr and is_tuple_containing_star(node)) or is_generator(node) ): return False diff --git a/src/black/mode.py b/src/black/mode.py index c7be0466f0b..702f580e979 100644 --- a/src/black/mode.py +++ b/src/black/mode.py @@ -233,11 +233,12 @@ class Preview(Enum): standardize_type_comments = auto() wrap_comprehension_in = auto() # Remove parentheses around multiple exception types in except and - # except* without as. See PEP 758 for details. + # except* without as. See PEP 758 for details. remove_parens_around_except_types = auto() normalize_cr_newlines = auto() fix_module_docstring_detection = auto() fix_type_expansion_split = auto() + remove_parens_from_assignment_lhs = auto() UNSTABLE_FEATURES: set[Preview] = { diff --git a/src/black/resources/black.schema.json b/src/black/resources/black.schema.json index bbb3cdfbb84..bed70a4bb22 100644 --- a/src/black/resources/black.schema.json +++ b/src/black/resources/black.schema.json @@ -91,7 +91,8 @@ "remove_parens_around_except_types", "normalize_cr_newlines", "fix_module_docstring_detection", - "fix_type_expansion_split" + "fix_type_expansion_split", + "remove_parens_from_assignment_lhs" ] }, "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." diff --git a/src/blib2to3/pgen2/tokenize.py b/src/blib2to3/pgen2/tokenize.py index 18503973804..4e3761f3028 100644 --- a/src/blib2to3/pgen2/tokenize.py +++ b/src/blib2to3/pgen2/tokenize.py @@ -211,8 +211,8 @@ def tokenize(source: str, grammar: Grammar | None = None) -> Iterator[TokenInfo] def printtoken( type: int, token: str, srow_col: Coord, erow_col: Coord, line: str ) -> None: # for testing - (srow, scol) = srow_col - (erow, ecol) = erow_col + srow, scol = srow_col + erow, ecol = erow_col print(f"{srow},{scol}-{erow},{ecol}:\t{tok_name[type]}\t{token!r}") diff --git a/tests/data/cases/remove_parens_from_lhs.py b/tests/data/cases/remove_parens_from_lhs.py new file mode 100644 index 00000000000..92d0731b87b --- /dev/null +++ b/tests/data/cases/remove_parens_from_lhs.py @@ -0,0 +1,36 @@ +# flags: --preview +# Remove unnecessary parentheses from LHS of assignments + + +def a(): + return [1, 2, 3] + + +# Single variable with unnecessary parentheses +(b) = a()[0] + +# Tuple unpacking with unnecessary parentheses +(c, *_) = a() + +# These should not be changed - parentheses are necessary +(d,) = a() # single-element tuple +e = (1 + 2) * 3 # RHS has precedence needs + +# output + +# Remove unnecessary parentheses from LHS of assignments + + +def a(): + return [1, 2, 3] + + +# Single variable with unnecessary parentheses +b = a()[0] + +# Tuple unpacking with unnecessary parentheses +c, *_ = a() + +# These should not be changed - parentheses are necessary +(d,) = a() # single-element tuple +e = (1 + 2) * 3 # RHS has precedence needs