Repository: psf/black
Pull request: fix: Don't allow unparenthesizing walruses
<!-- Hello! Thanks for submitting a PR. To help make things go a bit more
     smoothly we would appreciate that you go through this template. -->

### Description

Resolves #3758.

### Checklist - did you ...

<!-- If any of the following items aren't relevant for your contribution
     please still tick them so we know you've gone through the checklist.

    All user-facing changes should get an entry. Otherwise, signal to us
    this should get the magical label to silence the CHANGELOG entry check.
    Tests are required for bugfixes and new features. Documentation changes
    are necessary for formatting and most enhancement changes. -->

- [y] Add an entry in `CHANGES.md` if necessary?
- [y] Add / update tests if necessary?
- [-] Add new / update outdated documentation?

<!-- Just as a reminder, everyone in all psf/black spaces including PRs
     must follow the PSF Code of Conduct (link below).

     Finally, once again thanks for your time and effort. If you have any
     feedback in regards to your experience contributing here, please
     let us know!

     Helpful links:

      PSF COC: https://www.python.org/psf/conduct/
      Contributing docs: https://black.readthedocs.io/en/latest/contributing/index.html
      Chat on Python Discord: https://discord.gg/RtVdv86PrH -->


Problem statement:
cannot parse assignment expression in preview style since 23.1
Hello,

I seem to have found a bug in black's preview style regarding assignment expressions, that has been present since version 23.1.0.

**Describe the bug**

When using an assignment expression in my code example, black with preview=true complains it cannot parse the line. Black preview=false accepts the code happily and leaves no changes. 

**To Reproduce**

Here's my code example:

```python
# file.py
from pydriller import Commit

commits: list[Commit] = []
update_hashes: list[str] = []
upstream_messages: list[str] = []
parsed = [
    {
        "hash": commit.hash,
        "author": f"{commit.author.name} <{commit.author.email}>",
        # black 23.1 --preview can't parse the following line:
        "is_update": (up := commit.hash in update_hashes),
        "is_upstream": up and commit.msg in upstream_messages,
    }
    for commit in commits
]
```

And run it with these arguments:

```sh
$ black file.py --target-version py311 --preview
```

The resulting error is:

> `cannot format file.py: Cannot parse: 12:24:         "is_update": up := commit.hash in update_hashes,`

**Expected behavior**

It should parse the line like it does with preview=false.

Also, look at the error above. The line of code shown doesn't include the parentheses like it does in my source. Without the parens, cpython can't parse it either!

**Environment**

- Black's version: 23.3, also tested on main branch at g839ef35.
- OS and Python version: Linux/Python 3.11.4

**Additional context**

I love you lots <3

Hints from issue discussion:
Thanks for reporting! I'm guessing we incorrectly remove the parentheses here. Should be an easy fix.
Bisects to 658c8d8d96047c5ba77be4aecc2545a22d5e35b9 (#3440), cc @yilei

Commit message: format

Signed-off-by: RedGuy12 <paul@reid-family.org>

Patch to review:
diff --git a/CHANGES.md b/CHANGES.md
index 8fb8677dd77..2bd58ed49ff 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -22,6 +22,7 @@
 - Address a missing case in the change to allow empty lines at the beginning of all
   blocks, except immediately before a docstring (#4130)
 - For stubs, fix logic to enforce empty line after nested classes with bodies (#4141)
+- Fix crash when using a walrus in a dictionary (#4155)
 
 ### Configuration
 
diff --git a/src/black/linegen.py b/src/black/linegen.py
index 4d468ce0f2e..7c4b96ad82f 100644
--- a/src/black/linegen.py
+++ b/src/black/linegen.py
@@ -246,6 +246,7 @@ def visit_dictsetmaker(self, node: Node) -> Iterator[Line]:
                         if maybe_make_parens_invisible_in_atom(
                             child,
                             parent=node,
+                            mode=self.mode,
                             remove_brackets_around_comma=False,
                         ):
                             wrap_in_parentheses(node, child, visible=False)
@@ -267,6 +268,7 @@ def visit_funcdef(self, node: Node) -> Iterator[Line]:
                     if maybe_make_parens_invisible_in_atom(
                         child,
                         parent=node,
+                        mode=self.mode,
                         remove_brackets_around_comma=False,
                     ):
                         wrap_in_parentheses(node, child, visible=False)
@@ -371,7 +373,7 @@ def visit_power(self, node: Node) -> Iterator[Line]:
             ):
                 wrap_in_parentheses(node, leaf)
 
-        remove_await_parens(node)
+        remove_await_parens(node, mode=self.mode)
 
         yield from self.visit_default(node)
 
@@ -419,7 +421,9 @@ def foo(a: (int), b: (float) = 7): ...
         """
         if Preview.parenthesize_long_type_hints in self.mode:
             assert len(node.children) == 3
-            if maybe_make_parens_invisible_in_atom(node.children[2], parent=node):
+            if maybe_make_parens_invisible_in_atom(
+                node.children[2], parent=node, mode=self.mode
+            ):
                 wrap_in_parentheses(node, node.children[2], visible=False)
 
         yield from self.visit_default(node)
@@ -1340,15 +1344,17 @@ def normalize_invisible_parens(  # noqa: C901
                 if maybe_make_parens_invisible_in_atom(
                     child,
                     parent=node,
+                    mode=mode,
                     remove_brackets_around_comma=True,
                 ):
                     wrap_in_parentheses(node, child, visible=False)
             elif isinstance(child, Node) and node.type == syms.with_stmt:
-                remove_with_parens(child, node)
+                remove_with_parens(child, node, mode=mode)
             elif child.type == syms.atom:
                 if maybe_make_parens_invisible_in_atom(
                     child,
                     parent=node,
+                    mode=mode,
                 ):
                     wrap_in_parentheses(node, child, visible=False)
             elif is_one_tuple(child):
@@ -1401,7 +1407,7 @@ def _normalize_import_from(parent: Node, child: LN, index: int) -> None:
         parent.append_child(Leaf(token.RPAR, ""))
 
 
-def remove_await_parens(node: Node) -> None:
+def remove_await_parens(node: Node, mode: Mode) -> None:
     if node.children[0].type == token.AWAIT and len(node.children) > 1:
         if (
             node.children[1].type == syms.atom
@@ -1410,6 +1416,7 @@ def remove_await_parens(node: Node) -> None:
             if maybe_make_parens_invisible_in_atom(
                 node.children[1],
                 parent=node,
+                mode=mode,
                 remove_brackets_around_comma=True,
             ):
                 wrap_in_parentheses(node, node.children[1], visible=False)
@@ -1479,7 +1486,7 @@ def _maybe_wrap_cms_in_parens(
         node.insert_child(1, new_child)
 
 
-def remove_with_parens(node: Node, parent: Node) -> None:
+def remove_with_parens(node: Node, parent: Node, mode: Mode) -> None:
     """Recursively hide optional parens in `with` statements."""
     # Removing all unnecessary parentheses in with statements in one pass is a tad
     # complex as different variations of bracketed statements result in pretty
@@ -1501,21 +1508,23 @@ def remove_with_parens(node: Node, parent: Node) -> None:
         if maybe_make_parens_invisible_in_atom(
             node,
             parent=parent,
+            mode=mode,
             remove_brackets_around_comma=True,
         ):
             wrap_in_parentheses(parent, node, visible=False)
         if isinstance(node.children[1], Node):
-            remove_with_parens(node.children[1], node)
+            remove_with_parens(node.children[1], node, mode=mode)
     elif node.type == syms.testlist_gexp:
         for child in node.children:
             if isinstance(child, Node):
-                remove_with_parens(child, node)
+                remove_with_parens(child, node, mode=mode)
     elif node.type == syms.asexpr_test and not any(
         leaf.type == token.COLONEQUAL for leaf in node.leaves()
     ):
         if maybe_make_parens_invisible_in_atom(
             node.children[0],
             parent=node,
+            mode=mode,
             remove_brackets_around_comma=True,
         ):
             wrap_in_parentheses(node, node.children[0], visible=False)
@@ -1524,6 +1533,7 @@ def remove_with_parens(node: Node, parent: Node) -> None:
 def maybe_make_parens_invisible_in_atom(
     node: LN,
     parent: LN,
+    mode: Mode,
     remove_brackets_around_comma: bool = False,
 ) -> bool:
     """If it's safe, make the parens in the atom `node` invisible, recursively.
@@ -1545,6 +1555,10 @@ def maybe_make_parens_invisible_in_atom(
             and max_delimiter_priority_in_atom(node) >= COMMA_PRIORITY
         )
         or is_tuple_containing_walrus(node)
+        or (
+            Preview.wrap_long_dict_values_in_parens in mode
+            and is_walrus_assignment(node)
+        )
     ):
         return False
 
@@ -1580,6 +1594,7 @@ def maybe_make_parens_invisible_in_atom(
         maybe_make_parens_invisible_in_atom(
             middle,
             parent=parent,
+            mode=mode,
             remove_brackets_around_comma=remove_brackets_around_comma,
         )
 
diff --git a/tests/data/cases/walrus_in_dict.py b/tests/data/cases/walrus_in_dict.py
new file mode 100644
index 00000000000..c33eecd84a6
--- /dev/null
+++ b/tests/data/cases/walrus_in_dict.py
@@ -0,0 +1,7 @@
+# flags: --preview
+{
+    "is_update": (up := commit.hash in update_hashes)
+}
+
+# output
+{"is_update": (up := commit.hash in update_hashes)}


Code review comments:
diff_hunk: @@ -1545,6 +1555,10 @@ def maybe_make_parens_invisible_in_atom(
             and max_delimiter_priority_in_atom(node) >= COMMA_PRIORITY
         )
         or is_tuple_containing_walrus(node)
+        or (
+            Preview.wrap_long_dict_values_in_parens in mode
path: src/black/linegen.py
text: @user1:
Do we need the preview check? Intuitively I'd think we shouldn't because we'll almost always cause a crash if we remove the parens around a walrus.

@author:
Good point, resolved