Repository: psf/black

Issue:
When detecting Python versions, `f"{x=}"` should imply 3.8+
**Describe the bug**

The `=` specifier was added to f-strings in Python 3.8, but Black does not yet distinguish it from any other f-string, and therefore detects the version as 3.6+.  This leads to missed formatting opportunities, and sometimes problems in downstream projects since this is invalid syntax under earlier versions.  (e.g. https://github.com/Zac-HD/shed/issues/31, prompting this issue)

**To Reproduce**

```python
from black import detect_target_versions, parsing

code = parsing.lib2to3_parse("""
f'{x=}'
""")
print(detect_target_versions(code))
```
```
$ python repro.py
{<TargetVersion.PY36: 6>, <TargetVersion.PY37: 7>, <TargetVersion.PY38: 8>, <TargetVersion.PY39: 9>, <TargetVersion.PY310: 10>}
```


Discussion:
Since we don't have an f-string parser yet (lib2to3 doesn't parse inner f-string expressions), I don't think it is possible to detect in the conventional system. We could introduce another layer with the AST, or just wait for the future parser migrations (there are discussions around using LibCST, which can parser f-strings AFAIK). 
Black can detect f-string expressions though, which seems enough for implementing this, something based on this https://github.com/psf/black/blob/6417c99bfdbdc057e4a10aeff9967a751f4f85e9/src/black/trans.py#L1027-L1071

Since I am not familiar with the above code, I'm not sure, but reading its documentation, it looks like we might be able to do this with the help of the above.
I'm tentatively accepting this assuming it's technically feasible with `iter_fexpr_spans`. Hopefully it is.
Author of `iter_fexpr_spans` here, it's feasible for most normal cases. The case where it wouldn't help is with feature version detection in nested f-strings, e.g. `f"heard a rumour that { f'{1+1=}' } ... seems like it could be true"`, but that's niche enough that we can ignore it.

Resolving patch:
diff --git a/src/black/__init__.py b/src/black/__init__.py
--- a/src/black/__init__.py
+++ b/src/black/__init__.py
@@ -87,6 +87,7 @@
 from black.parsing import InvalidInput  # noqa F401
 from black.parsing import lib2to3_parse, parse_ast, stringify_ast
 from black.report import Changed, NothingChanged, Report
+from black.trans import iter_fexpr_spans
 from blib2to3.pgen2 import token
 from blib2to3.pytree import Leaf, Node
 
@@ -1240,6 +1241,7 @@ def get_features_used(  # noqa: C901
 
     Currently looking for:
     - f-strings;
+    - self-documenting expressions in f-strings (f"{x=}");
     - underscores in numeric literals;
     - trailing commas after * or ** in function signatures and calls;
     - positional only arguments in function signatures and lambdas;
@@ -1261,6 +1263,11 @@ def get_features_used(  # noqa: C901
             value_head = n.value[:2]
             if value_head in {'f"', 'F"', "f'", "F'", "rf", "fr", "RF", "FR"}:
                 features.add(Feature.F_STRINGS)
+                if Feature.DEBUG_F_STRINGS not in features:
+                    for span_beg, span_end in iter_fexpr_spans(n.value):
+                        if n.value[span_beg : span_end - 1].rstrip().endswith("="):
+                            features.add(Feature.DEBUG_F_STRINGS)
+                            break
 
         elif is_number_token(n):
             if "_" in n.value:
diff --git a/src/black/mode.py b/src/black/mode.py
--- a/src/black/mode.py
+++ b/src/black/mode.py
@@ -49,6 +49,7 @@ class Feature(Enum):
     ANN_ASSIGN_EXTENDED_RHS = 13
     EXCEPT_STAR = 14
     VARIADIC_GENERICS = 15
+    DEBUG_F_STRINGS = 16
     FORCE_OPTIONAL_PARENTHESES = 50
 
     # __future__ flags
@@ -81,6 +82,7 @@ class Feature(Enum):
     },
     TargetVersion.PY38: {
         Feature.F_STRINGS,
+        Feature.DEBUG_F_STRINGS,
         Feature.NUMERIC_UNDERSCORES,
         Feature.TRAILING_COMMA_IN_CALL,
         Feature.TRAILING_COMMA_IN_DEF,
@@ -93,6 +95,7 @@ class Feature(Enum):
     },
     TargetVersion.PY39: {
         Feature.F_STRINGS,
+        Feature.DEBUG_F_STRINGS,
         Feature.NUMERIC_UNDERSCORES,
         Feature.TRAILING_COMMA_IN_CALL,
         Feature.TRAILING_COMMA_IN_DEF,
@@ -106,6 +109,7 @@ class Feature(Enum):
     },
     TargetVersion.PY310: {
         Feature.F_STRINGS,
+        Feature.DEBUG_F_STRINGS,
         Feature.NUMERIC_UNDERSCORES,
         Feature.TRAILING_COMMA_IN_CALL,
         Feature.TRAILING_COMMA_IN_DEF,
@@ -120,6 +124,7 @@ class Feature(Enum):
     },
     TargetVersion.PY311: {
         Feature.F_STRINGS,
+        Feature.DEBUG_F_STRINGS,
         Feature.NUMERIC_UNDERSCORES,
         Feature.TRAILING_COMMA_IN_CALL,
         Feature.TRAILING_COMMA_IN_DEF,


Test patch:
diff --git a/tests/test_black.py b/tests/test_black.py
--- a/tests/test_black.py
+++ b/tests/test_black.py
@@ -310,6 +310,26 @@ def test_detect_pos_only_arguments(self) -> None:
         versions = black.detect_target_versions(root)
         self.assertIn(black.TargetVersion.PY38, versions)
 
+    def test_detect_debug_f_strings(self) -> None:
+        root = black.lib2to3_parse("""f"{x=}" """)
+        features = black.get_features_used(root)
+        self.assertIn(black.Feature.DEBUG_F_STRINGS, features)
+        versions = black.detect_target_versions(root)
+        self.assertIn(black.TargetVersion.PY38, versions)
+
+        root = black.lib2to3_parse(
+            """f"{x}"\nf'{"="}'\nf'{(x:=5)}'\nf'{f(a="3=")}'\nf'{x:=10}'\n"""
+        )
+        features = black.get_features_used(root)
+        self.assertNotIn(black.Feature.DEBUG_F_STRINGS, features)
+
+        # We don't yet support feature version detection in nested f-strings
+        root = black.lib2to3_parse(
+            """f"heard a rumour that { f'{1+1=}' } ... seems like it could be true" """
+        )
+        features = black.get_features_used(root)
+        self.assertNotIn(black.Feature.DEBUG_F_STRINGS, features)
+
     @patch("black.dump_to_file", dump_to_stderr)
     def test_string_quotes(self) -> None:
         source, expected = read_data("miscellaneous", "string_quotes")