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>}
```

Comments:

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 5b29f20..1fc8c65 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -33,6 +33,8 @@
 
 <!-- Changes to how Black can be configured -->
 
+- Black now uses the presence of debug f-strings to detect target version. (#3215)
+
 ### Documentation
 
 <!-- Major changes to documentation and policies. Small docs changes
diff --git a/src/black/__init__.py b/src/black/__init__.py
index 2a5c750..b8a9d03 100644
--- a/src/black/__init__.py
+++ b/src/black/__init__.py
@@ -87,6 +87,7 @@ from black.output import color_diff, diff, dump_to_file, err, ipynb_diff, out
 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
index 32b65d1..f6d0cbf 100644
--- 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 @@ VERSION_TO_FEATURES: Dict[TargetVersion, Set[Feature]] = {
     },
     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 @@ VERSION_TO_FEATURES: Dict[TargetVersion, Set[Feature]] = {
     },
     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 @@ VERSION_TO_FEATURES: Dict[TargetVersion, Set[Feature]] = {
     },
     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 @@ VERSION_TO_FEATURES: Dict[TargetVersion, Set[Feature]] = {
     },
     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
index bb7784d..81e7a9a 100644
--- a/tests/test_black.py
+++ b/tests/test_black.py
@@ -310,6 +310,26 @@ class BlackTestCase(BlackBaseTestCase):
         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")