Repository: cython/cython

Issue:
[BUG] Infinite recursion in binops
### Describe the bug

It's possible to get an infinite recursion error on binops https://github.com/pandas-dev/pandas/pull/56082 that doesn't exist in Python

### Code to reproduce the behaviour:

```cython
import cython

@cython.cclass
class C:
    def __add__(self, other):
        return NotImplemented

    def __radd__(self, other):
        return self.__add__(other)

C() + C()
```


### Expected behaviour

In Python this correctly gives

```
TypeError: unsupported operand type(s) for +: 'notimplemented.C' and 'notimplemented.C'
```

In Cython it gives

```
  File "notimplemented.pyx", line 17, in notimplemented.C.__radd__
    return self.__add__(other)
  File "notimplemented.pyx", line 17, in notimplemented.C.__radd__
    return self.__add__(other)
  File "notimplemented.pyx", line 17, in notimplemented.C.__radd__
    return self.__add__(other)
  [Previous line repeated 997 more times]
RecursionError: maximum recursion depth exceeded while calling a Python object
```

I think the issue is:

```
static PyMethodDef __pyx_methods_14notimplemented_C[] = {
  {"__radd__", (PyCFunction)__pyx_pw_14notimplemented_1C_3__radd__, METH_O|METH_COEXIST, 0},
  {"__reduce_cython__", (PyCFunction)(void*)(__Pyx_PyCFunction_FastCallWithKeywords)__pyx_pw_14notimplemented_1C_5__reduce_cython__, __Pyx_METH_FASTCALL|METH_KEYWORDS, 0},
  {"__setstate_cython__", (PyCFunction)(void*)(__Pyx_PyCFunction_FastCallWithKeywords)__pyx_pw_14notimplemented_1C_7__setstate_cython__, __Pyx_METH_FASTCALL|METH_KEYWORDS, 0},
  {0, 0, 0, 0}
};
```

Here we need to add `__add__` and make it point to the `__add__` function rather than our binop wrapper function. This will mean manually calling `C.__add__` skips the binop wrapper and calls the function exactly as written.

### OS

Linux

### Python version

3.11.5

### Cython version

Most recent master (but I think also 3.0.x)

### Additional context

_No response_


Discussion:

Resolving patch:
diff --git a/Cython/Compiler/Code.py b/Cython/Compiler/Code.py
--- a/Cython/Compiler/Code.py
+++ b/Cython/Compiler/Code.py
@@ -2299,12 +2299,18 @@ def put_init_var_to_py_none(self, entry, template = "%s", nanny=True):
             self.put_giveref('Py_None')
 
     def put_pymethoddef(self, entry, term, allow_skip=True, wrapper_code_writer=None):
-        is_reverse_number_slot = False
+        is_number_slot = False
         if entry.is_special or entry.name == '__getattribute__':
             from . import TypeSlots
-            is_reverse_number_slot = True
-            if entry.name not in special_py_methods and not TypeSlots.is_reverse_number_slot(entry.name):
-                if entry.name == '__getattr__' and not self.globalstate.directives['fast_getattr']:
+            if entry.name not in special_py_methods:
+                if TypeSlots.is_binop_number_slot(entry.name):
+                    # It's useful if numeric binops are created with meth coexist
+                    # so they can be called directly by looking up the name, skipping the
+                    # dispatch wrapper that enables the reverse slots.  This is most useful
+                    # when c_api_binop_methods is False, but there's no reason not to do it
+                    # all the time
+                    is_number_slot = True
+                elif entry.name == '__getattr__' and not self.globalstate.directives['fast_getattr']:
                     pass
                 # Python's typeobject.c will automatically fill in our slot
                 # in add_operators() (called by PyType_Ready) with a value
@@ -2323,8 +2329,8 @@ def put_pymethoddef(self, entry, term, allow_skip=True, wrapper_code_writer=None
         if cast != 'PyCFunction':
             func_ptr = '(void*)(%s)%s' % (cast, func_ptr)
         entry_name = entry.name.as_c_string_literal()
-        if is_reverse_number_slot:
-            # Unlike most special functions, reverse number operator slots are actually generated here
+        if is_number_slot:
+            # Unlike most special functions, binop numeric operator slots are actually generated here
             # (to ensure that they can be looked up). However, they're sometimes guarded by the preprocessor
             # so a bit of extra logic is needed
             slot = TypeSlots.get_slot_table(self.globalstate.directives).get_slot_by_method_name(entry.name)
@@ -2338,7 +2344,7 @@ def put_pymethoddef(self, entry, term, allow_skip=True, wrapper_code_writer=None
                 "|".join(method_flags),
                 entry.doc_cname if entry.doc else '0',
                 term))
-        if is_reverse_number_slot and preproc_guard:
+        if is_number_slot and preproc_guard:
             self.putln("#endif")
 
     def put_pymethoddef_wrapper(self, entry):
diff --git a/Cython/Compiler/TypeSlots.py b/Cython/Compiler/TypeSlots.py
--- a/Cython/Compiler/TypeSlots.py
+++ b/Cython/Compiler/TypeSlots.py
@@ -794,18 +794,17 @@ def get_slot_code_by_name(scope, slot_name):
     slot = get_slot_by_name(slot_name, scope.directives)
     return slot.slot_code(scope)
 
-def is_reverse_number_slot(name):
+def is_binop_number_slot(name):
     """
-    Tries to identify __radd__ and friends (so the METH_COEXIST flag can be applied).
+    Tries to identify __add__/__radd__ and friends (so the METH_COEXIST flag can be applied).
 
     There's no great consequence if it inadvertently identifies a few other methods
     so just use a simple rule rather than an exact list.
     """
-    if name.startswith("__r") and name.endswith("__"):
-        forward_name = name.replace("r", "", 1)
-        for meth in get_slot_table(None).PyNumberMethods:
-            if hasattr(meth, "right_slot"):
-                return True
+    slot_table = get_slot_table(None)
+    for meth in get_slot_table(None).PyNumberMethods:
+        if meth.is_binop and name in meth.user_methods:
+            return True
     return False
 
 


Test patch:
diff --git a/tests/run/binop_reverse_methods_GH2056.pyx b/tests/run/binop_reverse_methods_GH2056.pyx
--- a/tests/run/binop_reverse_methods_GH2056.pyx
+++ b/tests/run/binop_reverse_methods_GH2056.pyx
@@ -301,4 +301,30 @@ class PyVersionDependent:
         return 999
 
 
+@cython.cclass
+class CallMethodsDirectly:
+    """
+    A slightly more useful version of this pattern is used by
+    Pandas. In order to (a) not cause a recursion error and
+    (b) correctly follow Python behaviour, the call to __add__
+    should use the function as written and not our dispatch
+    wrapper.
+
+    >>> CallMethodsDirectly() + CallMethodsDirectly  #doctest: +ELLIPSIS
+    Traceback (most recent call last):
+    ...
+    TypeError: unsupported operand type...
+
+    We can also do this
+    >>> CallMethodsDirectly().__radd__(None)  #doctest: +ELLIPSIS
+    NotImplemented
+    """
+
+    def __add__(self, other):
+        return NotImplemented
+
+    def __radd__(self, other):
+        return self.__add__(other)
+
+
 # TODO: Test a class that only defines the `__r...__()` methods.