Repository: psf/black

Issue:
PEP 701 support breaks stability policy
https://github.com/psf/black/pull/3822 is amazing work. I've been sort of behind on all open source stuff, so I only just got to running it on my codebase at work. I found some reformatting, which violates the stability policy.

```
def foo():
    class aaa(bbbb):
        value: str = f"""
        fdsa
        asdf
        ```section
        {value}
        ```
        """
```
There were eleven instances of things that got changed


Discussion:
Oh actually this one is a maybe a little different (with line length 100):
```
def a():
    def b():
        def c():
            def d():
                log(
                    f"Received operation {server_operation.name} from "
                    f"{self.writer._transport.get_extra_info('peername')}",  # type: ignore[attr-defined]
                    level=0,
                )
```
Reformatting collapses the f-strings onto one line, which seems undesirable
cc @JelleZijlstra for awareness. If we need to break stability policy to get this out, it's worth it, but maybe it's not too hard to fix.
cc @tusharsadhwani.

What are you seeing exactly on your first example? I tried running current Black main (on Python 3.12.1) on a file with your code sample and didn't get any changes.
I can reproduce the second one though:

```
 % python -m black --diff src/black/shantanu2.py -l 100
--- src/black/shantanu2.py	2024-04-22 23:36:08.610531+00:00
+++ src/black/shantanu2.py	2024-04-22 23:36:27.151299+00:00
@@ -1,9 +1,8 @@
 def a():
     def b():
         def c():
             def d():
                 log(
-                    f"Received operation {server_operation.name} from "
-                    f"{self.writer._transport.get_extra_info('peername')}",  # type: ignore[attr-defined]
+                    f"Received operation {server_operation.name} from {self.writer._transport.get_extra_info('peername')}",  # type: ignore[attr-defined]
                     level=0,
                 )
would reformat src/black/shantanu2.py

All done! ✨ 🍰 ✨
1 file would be reformatted.
```
For the first one, you need to **not** have `unstable = true` (if you run inside black's directory you'll pick up `unstable = true` automatically). I just delete black's pyproject.toml

```
--- b.py	2024-04-22 23:47:32.201559+00:00
+++ b.py	2024-04-22 23:49:26.995636+00:00
@@ -1,9 +1,11 @@
 def foo():
     class aaa(bbbb):
-        value: str = f"""
+        value: str = (
+            f"""
         fdsa
         asdf
         ```section
         {value}
         ```
         """
+        )
would reformat b.py
```
I'm thinking it has to do with code that was special cased for `STRING` tokens, but now there's `FSTRING_START` tokens there.

Code like this:

https://github.com/psf/black/blob/551ede2825e4a92e9880b90ef517cce333a5cbfc/src/black/brackets.py#L263-L268

Interesting thing about second case is that the issue goes away if you remove the type ignore comment.
I'm planning to hold off on making a release until this is either fixed or we've investigated enough to convince ourselves that it's very difficult to fix.

I'll try to find some time to look into this myself too; @tusharsadhwani is probably right that it's about STRING special case that now also needs to look at f-string tokens. If so, the fix shouldn't be difficult. 
(in case useful for prioritising, I think the second one is much rarer, all the other hits in my work codebase look like the first case)
@hauntsaninja ~I'm unable to reproduce the first case, can you provide the command you're running?~

EDIT: nevermind. I got it to reproduce.

```
/tmp λ cat asd.py                          
def foo():
    class aaa(bbbb):
        value: str = f"""
        fdsa
        asdf
        ```section
        {value}
        ```
        """
/tmp λ /Users/shantanu/.virtualenvs/black-drsy/bin/python -m black asd.py --check --diff
--- asd.py	2024-04-23 19:21:00.283019+00:00
+++ asd.py	2024-04-23 19:21:33.367713+00:00
@@ -1,9 +1,11 @@
 def foo():
     class aaa(bbbb):
-        value: str = f"""
+        value: str = (
+            f"""
         fdsa
         asdf
         ```section
         {value}
         ```
         """
+        )
would reformat asd.py

Oh no! 💥 💔 💥
1 file would be reformatted.
```

Resolving patch:
diff --git a/src/black/linegen.py b/src/black/linegen.py
--- a/src/black/linegen.py
+++ b/src/black/linegen.py
@@ -37,6 +37,7 @@
     WHITESPACE,
     Visitor,
     ensure_visible,
+    fstring_to_string,
     get_annotation_type,
     is_arith_like,
     is_async_stmt_or_funcdef,
@@ -504,7 +505,7 @@ def visit_NUMBER(self, leaf: Leaf) -> Iterator[Line]:
 
     def visit_fstring(self, node: Node) -> Iterator[Line]:
         # currently we don't want to format and split f-strings at all.
-        string_leaf = _fstring_to_string(node)
+        string_leaf = fstring_to_string(node)
         node.replace(string_leaf)
         yield from self.visit_STRING(string_leaf)
 
@@ -574,12 +575,6 @@ def __post_init__(self) -> None:
             self.visit_guard = partial(v, keywords=Ø, parens={"if"})
 
 
-def _fstring_to_string(node: Node) -> Leaf:
-    """Converts an fstring node back to a string node."""
-    string_without_prefix = str(node)[len(node.prefix) :]
-    return Leaf(token.STRING, string_without_prefix, prefix=node.prefix)
-
-
 def _hugging_power_ops_line_to_string(
     line: Line,
     features: Collection[Feature],
@@ -1421,7 +1416,7 @@ def normalize_invisible_parens(  # noqa: C901
                 # of case will be not parsed as a Python keyword.
                 break
 
-            elif not (isinstance(child, Leaf) and is_multiline_string(child)):
+            elif not is_multiline_string(child):
                 wrap_in_parentheses(node, child, visible=False)
 
         comma_check = child.type == token.COMMA
diff --git a/src/black/nodes.py b/src/black/nodes.py
--- a/src/black/nodes.py
+++ b/src/black/nodes.py
@@ -762,8 +762,28 @@ def is_vararg(leaf: Leaf, within: Set[NodeType]) -> bool:
     return p.type in within
 
 
-def is_multiline_string(leaf: Leaf) -> bool:
+def is_fstring(node: Node) -> bool:
+    """Return True if the node is an f-string"""
+    return node.type == syms.fstring
+
+
+def fstring_to_string(node: Node) -> Leaf:
+    """Converts an fstring node back to a string node."""
+    string_without_prefix = str(node)[len(node.prefix) :]
+    string_leaf = Leaf(token.STRING, string_without_prefix, prefix=node.prefix)
+    string_leaf.lineno = node.get_lineno() or 0
+    return string_leaf
+
+
+def is_multiline_string(node: LN) -> bool:
     """Return True if `leaf` is a multiline string that actually spans many lines."""
+    if isinstance(node, Node) and is_fstring(node):
+        leaf = fstring_to_string(node)
+    elif isinstance(node, Leaf):
+        leaf = node
+    else:
+        return False
+
     return has_triple_quotes(leaf.value) and "\n" in leaf.value
 
 


Test patch:
diff --git a/tests/data/cases/pep_701.py b/tests/data/cases/pep_701.py
--- a/tests/data/cases/pep_701.py
+++ b/tests/data/cases/pep_701.py
@@ -110,6 +110,15 @@
     {1}_cte AS ()'''}
 """
 
+value: str = f'''foo
+'''
+
+log(
+    f"Received operation {server_operation.name} from "
+    f"{self.writer._transport.get_extra_info('peername')}",  # type: ignore[attr-defined]
+    level=0,
+)
+
 # output
 
 x = f"foo"
@@ -222,3 +231,12 @@
     WITH {f'''
     {1}_cte AS ()'''}
 """
+
+value: str = f"""foo
+"""
+
+log(
+    f"Received operation {server_operation.name} from "
+    f"{self.writer._transport.get_extra_info('peername')}",  # type: ignore[attr-defined]
+    level=0,
+)