Repository: cython/cython

Issue:
[BUG] self documenting expressions in f-strings
**Describe the bug**

Python 3.8 added support for the `=` specifier in f-strings to provide self documenting expressions. Using this feature makes a file fail to compile.

**To Reproduce**

test.py:
```test.py
x = 1
print(f"{x=}")
```

then run:

```
$ python test.py
x=1

$ cython -3 test.py

Error compiling Cython file:
------------------------------------------------------------
...
x = 1
print(f"{x=}")
            ^
------------------------------------------------------------

test.py:2:13: Expected ')', found '='
```

**Expected behavior**
No error compiling the file


**Environment (please complete the following information):**
 - OS: macOS
 - Python version 3.8.3
 - Cython version 0.29.21

**Additional context**

https://docs.python.org/3/whatsnew/3.8.html#f-strings-support-for-self-documenting-expressions-and-debugging



Discussion:
I can confirm this also in Cython==3.0a6 on windows.
Probably easy to implement in `Parsing.py`. PR welcome.

Resolving patch:
diff --git a/Cython/Compiler/ExprNodes.py b/Cython/Compiler/ExprNodes.py
--- a/Cython/Compiler/ExprNodes.py
+++ b/Cython/Compiler/ExprNodes.py
@@ -3340,7 +3340,7 @@ class FormattedValueNode(ExprNode):
     # {}-delimited portions of an f-string
     #
     # value           ExprNode                The expression itself
-    # conversion_char str or None             Type conversion (!s, !r, !a, or none, or 'd' for integer conversion)
+    # conversion_char str or None             Type conversion (!s, !r, !a, none, or 'd' for integer conversion)
     # format_spec     JoinedStrNode or None   Format string passed to __format__
     # c_format_spec   str or None             If not None, formatting can be done at the C level
 
diff --git a/Cython/Compiler/Parsing.py b/Cython/Compiler/Parsing.py
--- a/Cython/Compiler/Parsing.py
+++ b/Cython/Compiler/Parsing.py
@@ -1081,8 +1081,8 @@ def p_f_string(s, unicode_value, pos, is_raw):
                 if builder.chars:
                     values.append(ExprNodes.UnicodeNode(pos, value=builder.getstring()))
                     builder = StringEncoding.UnicodeLiteralBuilder()
-                next_start, expr_node = p_f_string_expr(s, unicode_value, pos, next_start, is_raw)
-                values.append(expr_node)
+                next_start, expr_nodes = p_f_string_expr(s, unicode_value, pos, next_start, is_raw)
+                values.extend(expr_nodes)
         elif c == '}':
             if part == '}}':
                 builder.append('}')
@@ -1098,12 +1098,16 @@ def p_f_string(s, unicode_value, pos, is_raw):
 
 
 def p_f_string_expr(s, unicode_value, pos, starting_index, is_raw):
-    # Parses a {}-delimited expression inside an f-string. Returns a FormattedValueNode
-    # and the index in the string that follows the expression.
+    # Parses a {}-delimited expression inside an f-string. Returns a list of nodes
+    # [UnicodeNode?, FormattedValueNode] and the index in the string that follows
+    # the expression.
+    #
+    # ? = Optional
     i = starting_index
     size = len(unicode_value)
     conversion_char = terminal_char = format_spec = None
     format_spec_str = None
+    expr_text = None
     NO_CHAR = 2**30
 
     nested_depth = 0
@@ -1143,11 +1147,17 @@ def p_f_string_expr(s, unicode_value, pos, starting_index, is_raw):
         elif c == '#':
             error(_f_string_error_pos(pos, unicode_value, i),
                   "format string cannot include #")
-        elif nested_depth == 0 and c in '!:}':
-            # allow != as a special case
-            if c == '!' and i + 1 < size and unicode_value[i + 1] == '=':
-                i += 1
-                continue
+        elif nested_depth == 0 and c in '><=!:}':
+            # allow special cases with '!' and '='
+            if i + 1 < size:
+                chars = c + unicode_value[i + 1]
+                if chars in ('!=', '==', '>=', '<='):
+                    i += 2  # we checked 2, so we can skip 2
+                    continue
+
+                if c in '><':  # allow single '<' and '>'
+                    i += 1
+                    continue
 
             terminal_char = c
             break
@@ -1161,6 +1171,16 @@ def p_f_string_expr(s, unicode_value, pos, starting_index, is_raw):
         error(_f_string_error_pos(pos, unicode_value, starting_index),
               "empty expression not allowed in f-string")
 
+    if terminal_char == '=':
+        i += 1
+        while i < size and unicode_value[i].isspace():
+            i += 1
+
+        if i < size:
+            terminal_char = unicode_value[i]
+            expr_text = unicode_value[starting_index:i]
+        # otherwise: error will be reported below
+
     if terminal_char == '!':
         i += 1
         if i + 2 > size:
@@ -1198,6 +1218,9 @@ def p_f_string_expr(s, unicode_value, pos, starting_index, is_raw):
 
         format_spec_str = unicode_value[start_format_spec:i]
 
+    if expr_text and conversion_char is None and format_spec_str is None:
+        conversion_char = 'r'
+
     if terminal_char != '}':
         error(_f_string_error_pos(pos, unicode_value, i),
               "missing '}' in format string expression" + (
@@ -1216,8 +1239,12 @@ def p_f_string_expr(s, unicode_value, pos, starting_index, is_raw):
     if format_spec_str:
         format_spec = ExprNodes.JoinedStrNode(pos, values=p_f_string(s, format_spec_str, pos, is_raw))
 
-    return i + 1, ExprNodes.FormattedValueNode(
-        pos, value=expr, conversion_char=conversion_char, format_spec=format_spec)
+    nodes = []
+    if expr_text:
+        nodes.append(ExprNodes.UnicodeNode(pos, value=StringEncoding.EncodedString(expr_text)))
+    nodes.append(ExprNodes.FormattedValueNode(pos, value=expr, conversion_char=conversion_char, format_spec=format_spec))
+
+    return i + 1, nodes
 
 
 # since PEP 448:


Test patch:
diff --git a/tests/run/test_fstring.pyx b/tests/run/test_fstring.pyx
--- a/tests/run/test_fstring.pyx
+++ b/tests/run/test_fstring.pyx
@@ -10,6 +10,10 @@ import unittest
 
 import sys
 IS_PY2 = sys.version_info[0] < 3
+if IS_PY2:
+    # Define `ascii` as `repr` for Python2 as it functions
+    # the same way in that version.
+    ascii = repr
 
 from Cython.Build.Inline import cython_inline
 from Cython.TestUtils import CythonTest
@@ -1122,8 +1126,7 @@ non-important content
         self.assertEqual(cy_eval('f"\\\n"'), '')
         self.assertEqual(cy_eval('f"\\\r"'), '')
 
-    """
-    def __test_debug_conversion(self):
+    def test_debug_conversion(self):
         x = 'A string'
         self.assertEqual(f'{x=}', 'x=' + repr(x))
         self.assertEqual(f'{x =}', 'x =' + repr(x))
@@ -1169,10 +1172,11 @@ non-important content
         self.assertEqual(f'{0!=1}', 'True')
         self.assertEqual(f'{0<=1}', 'True')
         self.assertEqual(f'{0>=1}', 'False')
-        self.assertEqual(f'{(x:="5")}', '5')
-        self.assertEqual(x, '5')
-        self.assertEqual(f'{(x:=5)}', '5')
-        self.assertEqual(x, 5)
+        # Walrus not implemented yet, skip
+        # self.assertEqual(f'{(x:="5")}', '5')
+        # self.assertEqual(x, '5')
+        # self.assertEqual(f'{(x:=5)}', '5')
+        # self.assertEqual(x, 5)
         self.assertEqual(f'{"="}', '=')
 
         x = 20
@@ -1226,7 +1230,7 @@ non-important content
         # the tabs to spaces just to shut up patchcheck.
         #self.assertEqual(f'X{x =}Y', 'Xx\t='+repr(x)+'Y')
         #self.assertEqual(f'X{x =       }Y', 'Xx\t=\t'+repr(x)+'Y')
-    """
+
 
     def test_walrus(self):
         x = 20
@@ -1234,6 +1238,8 @@ non-important content
         # spec of '=10'.
         self.assertEqual(f'{x:=10}', '        20')
 
+        # Note to anyone going to enable these: please have a look to the test
+        # above this one for more walrus cases to enable.
         """
         # This is an assignment expression, which requires parens.
         self.assertEqual(f'{(x:=10)}', '10')