Repository: cython/cython
Pull request: Support 2 and 3 arg __[ri]pow__
This change accepts both two and three argument versions for `__pow__`, `__rpow__` and `__ipow__`.

For the two arg versions it raises an exception if three arguments are passed (although accepts a third argument of 'None' since this is what Python does by default).

For the three arg versions it allows a default argument of None to be set (but no other default arguments).

One deviation from Python:

```
class C:
  def __pow__(a, b, c):
    return a, b, c
```

will raise `TypeError: C.__pow__() missing 1 required positional argument: 'c'`
for all of
```
C()**2
pow(C(), 2)
pow(C(), 2, None)
```

(i.e. it just looks to see if None has been passed and treats that are 2 args only).

I haven't done this - it seems like a backwards compatibility mistake for Cython. Although it would be possible.

I haven't tied any of this to `c_api_binop_methods`. I think it's independent, although if we did want to reject None on the three argument power function, we could use that directive.

Fixes #5160 

Problem statement:
[BUG] Required number of arguments for __pow__ prevents compiled and interpreted pure python code compatability
### Describe the bug

Cython mandates three argument for the special method __pow__ . My class makes no use of the third argument, but when compiling the code cython forces me to define one without a default value

That mean that code using the exponteation operator must have two different implementations depending on whether it is running as compiled code or as interpreted code. I believe this defeats the purpose of pure python, which is one of the goals of cython 3.

### Code to reproduce the behaviour:

```cython
@cython.cclass
class Pow:
    def __pow__(self, x):
        return x
```

Fails with 

```
(venv311) C:\Users\Juan\cruji-enaire>python setup.py build_ext --inplace
Compiling src/crujisim/cythontests.py because it changed.
[1/1] Cythonizing src/crujisim/cythontests.py

Error compiling Cython file:
------------------------------------------------------------
...
@cython.cclass
class Pow:
    def __pow__(self, x):
    ^
------------------------------------------------------------

src\crujisim\cythontests.py:514:4: Special method __pow__ has wrong number of arguments (2 declared, 3 expected)
```

When trying to compile with a default argument,

```cython
@cython.cclass
class Pow:
    def __pow__(self, x, m=None):
        return x
```

Now fails because it won't accept a default value

```
(venv311) C:\Users\Juan\cruji-enaire>python setup.py build_ext --inplace
Compiling src/crujisim/cythontests.py because it changed.
[1/1] Cythonizing src/crujisim/cythontests.py

Error compiling Cython file:
------------------------------------------------------------
...
@cython.cclass
class Pow:
    def __pow__(self, x, m=None):
                         ^
------------------------------------------------------------

src\crujisim\cythontests.py:514:25: This argument cannot have a default value
```

Finally, the solution that cython will be happy with:

```cython
@cython.cclass
class Pow:
    def __pow__(self, x, m):
        return x
```

fails in interpreted mode because now the third argument is mandatory

```
In [1]: from crujisim.cythontests import Pow

In [2]: p = Pow()

In [3]: p ** 5
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In [3], line 1
----> 1 p ** 5

TypeError: Pow.__pow__() missing 1 required positional argument: 'm'
```



### Expected behaviour

I expect to be able to write a valid python .py file that I can optionally compile with cython to obtain speed advantages, but should work both compiled and interpreted.

I can work around it using `@cython.compiled`, but I believe  this should not be necessary

### Environment

OS: Windows
Python version 3.11
Cython version 3.0.0a11


### Additional context

_No response_

Hints from issue discussion:
I think this would likely be tricky but not impossible (so we probably should do it, but I wouldn't expect it to be soon).

The special methods of cclasses map pretty directly onto the C API slots, and in this case the C API slot only defines the 3 argument version.

In terms of implementation there's two things we could do:
1. Special-case it to allow a 3rd default argument of `None` (since this is the value that the Python internals use by default). We wouldn't allow any other default values.
2. Convert the two argument version into a small wrapper function that just checks the third argument is `None`.
Python gives a `TypeError` if the 3-argument version is used but not implemented:
```python
>>> class C:
...   def __pow__(self, other): return 5 * other
... 
>>> pow(C(), 3)
15
>>> pow(C(), 3, 2)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: __pow__() takes 2 positional arguments but 3 were given
```
I think we need to generate a wrapper slot function here that raises this exception if a third argument is passed. Although, we already *do* generate a wrapper function any way (in `ModuleNode.generate_binop_function()`), so maybe we can fold that special case into it and accept both signatures?
BTW, this doesn't seem specific to Python code. It seems helpful to support this also in Cython code. We should generally allow 2 or 3 arguments for  `__pow__` and let users choose which they prefer to implement.

Commit message: Fix other bugs

Patch to review:
diff --git a/Cython/Compiler/ModuleNode.py b/Cython/Compiler/ModuleNode.py
index 0a730856297..24349b1f10a 100644
--- a/Cython/Compiler/ModuleNode.py
+++ b/Cython/Compiler/ModuleNode.py
@@ -2252,7 +2252,7 @@ def generate_binop_function(self, scope, slot, code, pos):
         if slot.left_slot.signature in (TypeSlots.binaryfunc, TypeSlots.ibinaryfunc):
             slot_type = 'binaryfunc'
             extra_arg = extra_arg_decl = ''
-        elif slot.left_slot.signature in (TypeSlots.ternaryfunc, TypeSlots.iternaryfunc):
+        elif slot.left_slot.signature in (TypeSlots.powternaryfunc, TypeSlots.ipowternaryfunc):
             slot_type = 'ternaryfunc'
             extra_arg = ', extra_arg'
             extra_arg_decl = ', PyObject* extra_arg'
diff --git a/Cython/Compiler/Nodes.py b/Cython/Compiler/Nodes.py
index 66b4f97cf8e..391914dfc82 100644
--- a/Cython/Compiler/Nodes.py
+++ b/Cython/Compiler/Nodes.py
@@ -17,7 +17,7 @@
 from itertools import chain
 
 from . import Builtin
-from .Errors import error, warning, InternalError, CompileError, CannotSpecialize
+from .Errors import error, warning, InternalError, CompileError, CannotSpecialize, format_position
 from . import Naming
 from . import PyrexTypes
 from . import TypeSlots
@@ -888,6 +888,7 @@ class CArgDeclNode(Node):
     is_self_arg = 0
     is_type_arg = 0
     is_generic = 1
+    is_special_method_optional = False
     kw_only = 0
     pos_only = 0
     not_none = 0
@@ -1853,6 +1854,10 @@ def analyse_default_values(self, env):
                 if arg.is_generic:
                     arg.default = arg.default.analyse_types(env)
                     arg.default = arg.default.coerce_to(arg.type, env)
+                elif arg.is_special_method_optional:
+                    if not arg.default.is_none:
+                        error(arg.pos, "This argument cannot have a non-None default value")
+                        arg.default = None
                 else:
                     error(arg.pos, "This argument cannot have a default value")
                     arg.default = None
@@ -3269,7 +3274,8 @@ def analyse_signature(self, env):
                         self.entry.signature = TypeSlots.ibinaryfunc
 
         sig = self.entry.signature
-        nfixed = sig.num_fixed_args()
+        nfixed = sig.max_num_fixed_args()
+        min_nfixed = sig.min_num_fixed_args()
         if (sig is TypeSlots.pymethod_signature and nfixed == 1
                and len(self.args) == 0 and self.star_arg):
             # this is the only case where a diverging number of
@@ -3277,10 +3283,10 @@ def analyse_signature(self, env):
             # 'self' parameter as in method(*args)
             sig = self.entry.signature = TypeSlots.pyfunction_signature  # self is not 'really' used
             self.self_in_stararg = 1
-            nfixed = 0
+            nfixed = min_nfixed = 0
 
         if self.is_staticmethod and env.is_c_class_scope:
-            nfixed = 0
+            nfixed = min_nfixed = 0
             self.self_in_stararg = True  # FIXME: why for staticmethods?
 
             self.entry.signature = sig = copy.copy(sig)
@@ -3295,6 +3301,8 @@ def analyse_signature(self, env):
         for i in range(min(nfixed, len(self.args))):
             arg = self.args[i]
             arg.is_generic = 0
+            if i >= min_nfixed:
+                arg.is_special_method_optional = True
             if sig.is_self_arg(i) and not self.is_staticmethod:
                 if self.is_classmethod:
                     arg.is_type_arg = 1
@@ -3311,7 +3319,7 @@ def analyse_signature(self, env):
                     else:
                         arg.needs_conversion = 1
 
-        if nfixed > len(self.args):
+        if min_nfixed > len(self.args):
             self.bad_signature()
             return
         elif nfixed < len(self.args):
@@ -3348,9 +3356,11 @@ def analyse_signature(self, env):
 
     def bad_signature(self):
         sig = self.entry.signature
-        expected_str = "%d" % sig.num_fixed_args()
+        expected_str = "%d" % sig.min_num_fixed_args()
         if sig.has_generic_args:
             expected_str += " or more"
+        elif sig.optional_object_arg_count:
+            expected_str += " to %d" % sig.max_num_fixed_args()
         name = self.name
         if name.startswith("__") and name.endswith("__"):
             desc = "Special method"
@@ -3737,8 +3747,6 @@ def generate_function_header(self, code, with_pymethdef, proto_only=0):
         entry = self.target.entry
         if not entry.is_special and sig.method_flags() == [TypeSlots.method_noargs]:
             arg_code_list.append("CYTHON_UNUSED PyObject *unused")
-        if entry.scope.is_c_class_scope and entry.name == "__ipow__":
-            arg_code_list.append("CYTHON_UNUSED PyObject *unused")
         if sig.has_generic_args:
             varargs_args = "PyObject *%s, PyObject *%s" % (
                     Naming.args_cname, Naming.kwds_cname)
@@ -3750,6 +3758,9 @@ def generate_function_header(self, code, with_pymethdef, proto_only=0):
                         fastcall_args, varargs_args))
             else:
                 arg_code_list.append(varargs_args)
+        if entry.is_special:
+            for n in range(len(self.args), sig.max_num_fixed_args()):
+                arg_code_list.append("CYTHON_UNUSED PyObject *unused_arg_%s" % n)
         arg_code = ", ".join(arg_code_list)
 
         # Prevent warning: unused function '__pyx_pw_5numpy_7ndarray_1__getbuffer__'
@@ -4521,6 +4532,32 @@ def generate_argument_type_tests(self, code):
                                           arg.type.is_buffer or
                                           arg.type.is_memoryviewslice):
                 self.generate_arg_none_check(arg, code)
+        if self.target.entry.is_special:
+            for n in reversed(range(len(self.args), self.signature.max_num_fixed_args())):
+                # for special functions with optional args (e.g. power which can
+                # take 2 or 3 args), unused args are None since this is what the
+                # compilers sets
+                if self.target.entry.name == "__ipow__":
+                    # Bug in Python < 3.8 - __ipow__ is used as a binary function
+                    # and attempts to access the third argument will always fail
+                    code.putln("#if PY_VERSION_HEX >= 0x03080000")
+                code.putln("if (unlikely(unused_arg_%s != Py_None)) {" % n)
+                code.putln(
+                    'PyErr_SetString(PyExc_TypeError, '
+                    '"%s() takes %s arguments but %s were given");' % (
+                        self.target.entry.qualified_name, self.signature.max_num_fixed_args(), n))
+                code.putln("%s;" % code.error_goto(self.pos))
+                code.putln("}")
+                if self.target.entry.name == "__ipow__":
+                    code.putln("#endif /*PY_VERSION_HEX >= 0x03080000*/")
+            if self.target.entry.name == "__ipow__" and len(self.args) != 2:
+                # It's basically impossible to safely support it:
+                # Class().__ipow__(1) is guarranteed to crash.
+                # therefore, just emit a C error as the safest thing to do.
+                code.putln("#if PY_VERSION_HEX < 0x03080000")
+                code.putln('#error "%s: 3-argument %s cannot be used in Python<3.8"' % (
+                            format_position(self.pos), self.target.entry.qualified_name))
+                code.putln('#endif')
 
     def error_value(self):
         return self.signature.error_value
diff --git a/Cython/Compiler/TypeSlots.py b/Cython/Compiler/TypeSlots.py
index 84367466cef..e730bb9686e 100644
--- a/Cython/Compiler/TypeSlots.py
+++ b/Cython/Compiler/TypeSlots.py
@@ -52,6 +52,7 @@ class Signature(object):
     #    '*'  rest of args passed as generic Python
     #           arg tuple and kw dict (must be last
     #           char in format string)
+    #    '?'  optional object arg (currently for pow only)
 
     format_map = {
         'O': PyrexTypes.py_object_type,
@@ -71,6 +72,7 @@ class Signature(object):
         'S': PyrexTypes.c_char_ptr_ptr_type,
         'r': PyrexTypes.c_returncode_type,
         'B': PyrexTypes.c_py_buffer_ptr_type,
+        '?': PyrexTypes.py_object_type
         # 'T', '-' and '*' are handled otherwise
         # and are not looked up in here
     }
@@ -95,12 +97,15 @@ class Signature(object):
     def __init__(self, arg_format, ret_format, nogil=False):
         self.has_dummy_arg = False
         self.has_generic_args = False
+        self.optional_object_arg_count = 0
         if arg_format[:1] == '-':
             self.has_dummy_arg = True
             arg_format = arg_format[1:]
         if arg_format[-1:] == '*':
             self.has_generic_args = True
             arg_format = arg_format[:-1]
+        if arg_format[-1:] == '?':
+            self.optional_object_arg_count += 1
         self.fixed_arg_format = arg_format
         self.ret_format = ret_format
         self.error_value = self.error_value_map.get(ret_format, None)
@@ -114,7 +119,10 @@ def __repr__(self):
             ', '.join(self.fixed_arg_format),
             '*' if self.has_generic_args else '')
 
-    def num_fixed_args(self):
+    def min_num_fixed_args(self):
+        return self.max_num_fixed_args() - self.optional_object_arg_count
+
+    def max_num_fixed_args(self):
         return len(self.fixed_arg_format)
 
     def is_self_arg(self, i):
@@ -142,7 +150,7 @@ def exception_value(self):
     def function_type(self, self_arg_override=None):
         #  Construct a C function type descriptor for this signature
         args = []
-        for i in range(self.num_fixed_args()):
+        for i in range(self.max_num_fixed_args()):
             if self_arg_override is not None and self.is_self_arg(i):
                 assert isinstance(self_arg_override, PyrexTypes.CFuncTypeArg)
                 args.append(self_arg_override)
@@ -827,8 +835,8 @@ def is_reverse_number_slot(name):
 unaryfunc = Signature("T", "O")            # typedef PyObject * (*unaryfunc)(PyObject *);
 binaryfunc = Signature("OO", "O")          # typedef PyObject * (*binaryfunc)(PyObject *, PyObject *);
 ibinaryfunc = Signature("TO", "O")         # typedef PyObject * (*binaryfunc)(PyObject *, PyObject *);
-ternaryfunc = Signature("OOO", "O")        # typedef PyObject * (*ternaryfunc)(PyObject *, PyObject *, PyObject *);
-iternaryfunc = Signature("TOO", "O")       # typedef PyObject * (*ternaryfunc)(PyObject *, PyObject *, PyObject *);
+powternaryfunc = Signature("OO?", "O")        # typedef PyObject * (*ternaryfunc)(PyObject *, PyObject *, PyObject *);
+ipowternaryfunc = Signature("TO?", "O")       # typedef PyObject * (*ternaryfunc)(PyObject *, PyObject *, PyObject *);
 callfunc = Signature("T*", "O")            # typedef PyObject * (*ternaryfunc)(PyObject *, PyObject *, PyObject *);
 inquiry = Signature("T", "i")              # typedef int (*inquiry)(PyObject *);
 lenfunc = Signature("T", "z")              # typedef Py_ssize_t (*lenfunc)(PyObject *);
@@ -914,7 +922,7 @@ def __init__(self, old_binops):
         self.substructures = []   # List of all SuiteSlot instances
 
         bf = binaryfunc if old_binops else ibinaryfunc
-        tf = ternaryfunc if old_binops else iternaryfunc
+        ptf = powternaryfunc if old_binops else ipowternaryfunc
 
         #  Descriptor tables for the slots of the various type object
         #  substructures, in the order they appear in the structure.
@@ -926,7 +934,7 @@ def __init__(self, old_binops):
                       ifdef = PyNumberMethods_Py2only_GUARD),
             BinopSlot(bf, "nb_remainder", "__mod__", method_name_to_slot),
             BinopSlot(bf, "nb_divmod", "__divmod__", method_name_to_slot),
-            BinopSlot(tf, "nb_power", "__pow__", method_name_to_slot),
+            BinopSlot(ptf, "nb_power", "__pow__", method_name_to_slot),
             MethodSlot(unaryfunc, "nb_negative", "__neg__", method_name_to_slot),
             MethodSlot(unaryfunc, "nb_positive", "__pos__", method_name_to_slot),
             MethodSlot(unaryfunc, "nb_absolute", "__abs__", method_name_to_slot),
@@ -955,8 +963,7 @@ def __init__(self, old_binops):
             MethodSlot(ibinaryfunc, "nb_inplace_divide", "__idiv__", method_name_to_slot,
                        ifdef = PyNumberMethods_Py2only_GUARD),
             MethodSlot(ibinaryfunc, "nb_inplace_remainder", "__imod__", method_name_to_slot),
-            MethodSlot(ibinaryfunc, "nb_inplace_power", "__ipow__",
-                       method_name_to_slot),  # actually ternaryfunc!!!
+            MethodSlot(ptf, "nb_inplace_power", "__ipow__", method_name_to_slot),
             MethodSlot(ibinaryfunc, "nb_inplace_lshift", "__ilshift__", method_name_to_slot),
             MethodSlot(ibinaryfunc, "nb_inplace_rshift", "__irshift__", method_name_to_slot),
             MethodSlot(ibinaryfunc, "nb_inplace_and", "__iand__", method_name_to_slot),
diff --git a/docs/src/userguide/special_methods.rst b/docs/src/userguide/special_methods.rst
index e6635b5022f..b5bfc761a96 100644
--- a/docs/src/userguide/special_methods.rst
+++ b/docs/src/userguide/special_methods.rst
@@ -378,6 +378,8 @@ used the bidirectional C slot signature for the regular methods, thus making the
 first argument ambiguous (not 'self' typed).
 Since Cython 3.0, the operator calls are passed to the respective special methods.
 See the section on :ref:`Arithmetic methods <arithmetic_methods>` above.
+Cython 0.x also did not support the 2 argument version of ``__pow__`` and 
+``__rpow__``, or the 3 argument version of ``__ipow__``.
 
 Numeric conversions
 ^^^^^^^^^^^^^^^^^^^
@@ -422,7 +424,7 @@ https://docs.python.org/3/reference/datamodel.html#emulating-numeric-types
 +-----------------------+---------------------------------------+-------------+-----------------------------------------------------+
 | __imod__ 	        | self, x 	                        | object      | `%=` operator                                       |
 +-----------------------+---------------------------------------+-------------+-----------------------------------------------------+
-| __ipow__ 	        | self, y, z                            | object      | `**=` operator                                      |
+| __ipow__ 	        | self, y, [z]                          | object      | `**=` operator                                      |
 +-----------------------+---------------------------------------+-------------+-----------------------------------------------------+
 | __ilshift__ 	        | self, x 	                        | object      | `<<=` operator                                      |
 +-----------------------+---------------------------------------+-------------+-----------------------------------------------------+
diff --git a/runtests.py b/runtests.py
index d0f6b7b2089..98c9dd76f7f 100755
--- a/runtests.py
+++ b/runtests.py
@@ -499,9 +499,11 @@ def get_openmp_compiler_flags(language):
                                          'run.pep557_dataclasses',  # dataclasses module
                                          'run.test_dataclasses',
                                          ]),
+    (3,8): (operator.lt, lambda x: x in ['run.special_methods_T561_py38',
+                                         ]),
     (3,11,999): (operator.gt, lambda x: x in ['run.py_unicode_strings',
                                          ]),
-    
+
 }
 
 INCLUDE_DIRS = [ d for d in os.getenv('INCLUDE', '').split(os.pathsep) if d ]
diff --git a/tests/errors/e_argdefault.pyx b/tests/errors/e_argdefault.pyx
index 43e69ae6f36..afcf0e3255f 100644
--- a/tests/errors/e_argdefault.pyx
+++ b/tests/errors/e_argdefault.pyx
@@ -1,19 +1,27 @@
 # mode: error
 
 cdef spam(int i, char *s = "blarg", float f): # can't have default value
-	pass
+    pass
 
 def swallow(x, y = 42, z): # non-default after default
-	pass
+    pass
 
 cdef class Grail:
 
-	def __add__(x, y = 42): # can't have default value
-		pass
+    def __add__(x, y = 42): # can't have default value
+        pass
+
+    def __pow__(x, y, z=10):  # default must be None
+        pass
+
+    def __rpow__(x, y=2, z=None):  # z is OK, y isn't
+        pass
 
 _ERRORS = u"""
 3:9: Non-default argument follows default argument
 3:36: Non-default argument following default argument
 6:23: Non-default argument following default argument
-11:16: This argument cannot have a default value
+11:19: This argument cannot have a default value
+14:22: This argument cannot have a non-None default value
+17:20: This argument cannot have a default value
 """
diff --git a/tests/run/special_methods_T561.pyx b/tests/run/special_methods_T561.pyx
index bd68291e7e7..ecf94cef52d 100644
--- a/tests/run/special_methods_T561.pyx
+++ b/tests/run/special_methods_T561.pyx
@@ -18,9 +18,13 @@
 # Regarding ticket 3, we should additionally test that unbound method
 # calls to these special methods (e.g. ExtType.__init__()) do not use
 # a runtime lookup indirection.
+#
+# Additional tests added for 2 and 3 argument pow and ipow
 
 import sys
 
+from cpython.number cimport PyNumber_InPlacePower
+
 __doc__ = u"""
     >>> # If you define either setitem or delitem, you get wrapper objects
     >>> # for both methods.  (This behavior is unchanged by #561.)
@@ -961,7 +965,7 @@ cdef class ReverseMethodsExist:
 cdef class ArgumentTypeConversions:
     """
     The user can set the signature of special method arguments so that
-    it doesn't match the C signature. This just tests that a few 
+    it doesn't match the C signature. This just tests that a few
     variations work
 
     >>> obj = ArgumentTypeConversions()
@@ -997,3 +1001,112 @@ cdef class ArgumentTypeConversions:
     # force conversion of flags (int) to double
     def __getbuffer__(self, Py_buffer *buffer, double flags):
         raise RuntimeError("From __getbuffer__ with flags {}".format(flags))
+
+
+cdef class TwoArgPow:
+    """
+    >>> print(TwoArgPow('a')**2)
+    a**2
+    >>> print(pow(TwoArgPow('a'), 3))
+    a**3
+    >>> pow(TwoArgPow('a'), 'x', 'y')
+    Traceback (most recent call last):
+        ...
+    TypeError: special_methods_T561.TwoArgPow.__pow__() takes 3 arguments but 2 were given
+    """
+    cdef str name
+
+    def __init__(self, name):
+        self.name = name
+
+    def __pow__(self, other):
+        return f"{self.name}**{other}"
+
+
+cdef class TwoArgRPow:
+    """
+    >>> print(2**TwoArgRPow('a'))
+    a**2
+    >>> print(pow(3, TwoArgRPow('a')))
+    a**3
+    >>> pow('x', TwoArgRPow('a'), 'y')
+    Traceback (most recent call last):
+        ...
+    TypeError: special_methods_T561.TwoArgRPow.__rpow__() takes 3 arguments but 2 were given
+    """
+    cdef str name
+
+    def __init__(self, name):
+        self.name = name
+
+    def __rpow__(self, other):
+        return f"{self.name}**{other}"
+
+
+def ipow(a, b, c):
+    # As far as DW can tell, calling through this C API call is the
+    # only way to actually use ternary __ipow__
+    return PyNumber_InPlacePower(a, b, c)
+
+
+cdef class TwoArgIPow:
+    """
+    >>> a = TwoArgIPow('a')
+    >>> a**=2
+    >>> print(a)
+    a**2
+    """
+    cdef str name
+
+    def __init__(self, name):
+        self.name = name
+
+    def __ipow__(self, other):
+        return f"{self.name}**{other}"
+
+if sys.version_info >= (3, 8):
+    # Due to a bug this check can't usefully work in Python <3.8
+    __doc__ += """
+>>> ipow(TwoArgIPow('a'), 'x', 'y')
+Traceback (most recent call last):
+    ...
+TypeError: special_methods_T561.TwoArgIPow.__ipow__() takes 3 arguments but 2 were given
+    """
+
+
+cdef class TwoOrThreeArgPow:
+    """
+    >>> print(TwoOrThreeArgPow('a')**2)
+    a**2[None]
+    >>> print(pow(TwoOrThreeArgPow('a'), 3))
+    a**3[None]
+    >>> print(pow(TwoOrThreeArgPow('a'), 'x', 'y'))
+    a**x[y]
+    """
+    cdef str name
+
+    def __init__(self, name):
+        self.name = name
+
+    def __pow__(self, other, base=None):
+        return f"{self.name}**{other}[{base}]"
+
+
+cdef class TwoOrThreeArgRPow:
+    """
+    >>> print(2**TwoOrThreeArgRPow('a'))
+    a**2[None]
+    >>> print(pow(3, TwoOrThreeArgRPow('a')))
+    a**3[None]
+    >>> print(pow('x', TwoOrThreeArgRPow('a'), 'y'))
+    a**x[y]
+    """
+    cdef str name
+
+    def __init__(self, name):
+        self.name = name
+
+    def __rpow__(self, other, base=None):
+        return f"{self.name}**{other}[{base}]"
+
+# See special_methods_T561_py38 for some extra tests for ipow
diff --git a/tests/run/special_methods_T561_py38.pyx b/tests/run/special_methods_T561_py38.pyx
new file mode 100644
index 00000000000..9908a2504fe
--- /dev/null
+++ b/tests/run/special_methods_T561_py38.pyx
@@ -0,0 +1,48 @@
+# mode: run
+
+from cpython.number cimport PyNumber_InPlacePower
+
+def ipow(a, b, c):
+    # As far as DW can tell, calling through this C API call is the
+    # only way to actually use ternary __ipow__
+    return PyNumber_InPlacePower(a, b, c)
+
+# three-arg ipow can only work safely on Py3.8+
+# and so tests are in a separate file
+
+cdef class TwoOrThreeArgIPow:
+    """
+    >>> a = TwoOrThreeArgIPow('a')
+    >>> a**=2
+    >>> print(a)
+    a**2[None]
+    >>> print(ipow(TwoOrThreeArgIPow('a'), 'x', 'y'))
+    a**x[y]
+    """
+    cdef str name
+
+    def __init__(self, name):
+        self.name = name
+
+    def __ipow__(self, other, base=None):
+        return f"{self.name}**{other}[{base}]"
+
+
+cdef class ThreeArgIPow:
+    """
+    Note that it's not possible to detect if this is called in a 2-arg context
+    since the Python interpreter just passes None
+    >>> a = ThreeArgIPow('a')
+    >>> a**=2
+    >>> print(a)
+    a**2[None]
+    >>> print(ipow(ThreeArgIPow('a'), 'x', 'y'))
+    a**x[y]
+    """
+    cdef str name
+
+    def __init__(self, name):
+        self.name = name
+
+    def __ipow__(self, other, base):
+        return f"{self.name}**{other}[{base}]"


Code review comments:
diff_hunk: @@ -4521,6 +4532,32 @@ def generate_argument_type_tests(self, code):
                                           arg.type.is_buffer or
                                           arg.type.is_memoryviewslice):
                 self.generate_arg_none_check(arg, code)
+        if self.target.entry.is_special:
+            for n in reversed(range(len(self.args), self.signature.max_num_fixed_args())):
+                # for special functions with optional args (e.g. power which can
+                # take 2 or 3 args), unused args are None since this is what the
+                # compilers sets
+                if self.target.entry.name == "__ipow__":
+                    # Bug in Python < 3.8 - __ipow__ is used as a binary function
+                    # and attempts to access the third argument will always fail
+                    code.putln("#if PY_VERSION_HEX >= 0x03080000")
+                code.putln("if (unlikely(unused_arg_%s != Py_None)) {" % n)
+                code.putln(
+                    'PyErr_SetString(PyExc_TypeError, '
+                    '"%s() takes %s arguments but %s were given");' % (
+                        self.target.entry.qualified_name, self.signature.max_num_fixed_args(), n))
+                code.putln("%s;" % code.error_goto(self.pos))
+                code.putln("}")
+                if self.target.entry.name == "__ipow__":
+                    code.putln("#endif /*PY_VERSION_HEX >= 0x03080000*/")
+            if self.target.entry.name == "__ipow__" and len(self.args) != 2:
+                # It's basically impossible to safely support it:
+                # Class().__ipow__(1) is guarranteed to crash.
+                # therefore, just emit a C error as the safest thing to do.
+                code.putln("#if PY_VERSION_HEX < 0x03080000")
+                code.putln('#error "%s: 3-argument %s cannot be used in Python<3.8"' % (
+                            format_position(self.pos), self.target.entry.qualified_name))
+                code.putln('#endif')
path: Cython/Compiler/Nodes.py
text: @user1:
An `#error` seems unhelpful here. It seems ok to implement the method, even if it doesn't work in Py<3.8. The usual effect would just be less efficient code, most of the time. I'd rather keep the slot empty in older versions (it's effectively not implemented). You'd probably still get a C compiler warning that the function is unused, which seems enough to make users aware that it's … not being used. We could also mention this shortcoming in the special method docs.

@author:
On a second look, I agree that an `#error` seems like over-kill.

I've replaced it with an exception on call. That seems like the best way to give a clear explanation to the user. The crash is only is you try to access the third argument, so raising an exception is safe.

The other option might be runtime warning +  return `NotImplemented` - that'd fall back to `__pow__` as you say mostly so might be better.

@author:
Thinking about it more, not implemented would be better. I'll come back to this later... Marking as draft just so it doesn't get merged before then

@author:
I had another look at it:
* not assigning the type slot is really hard - you just don't have the right information available when the code is generated.
* Runtime warning + NotImplemented doesn't fall back to `__pow__`.
* Therefore, I think raising an error is as good a solution as anything. Especially since this is really rare - the only way to call a 3-arg `__ipow__` is from a C API function and it isn't possible directly from Python. Therefore I don't think it's worth wasting too much more time on.

@user1:
A runtime error is ok for me.

diff_hunk: @@ -827,8 +835,8 @@ def is_reverse_number_slot(name):
 unaryfunc = Signature("T", "O")            # typedef PyObject * (*unaryfunc)(PyObject *);
 binaryfunc = Signature("OO", "O")          # typedef PyObject * (*binaryfunc)(PyObject *, PyObject *);
 ibinaryfunc = Signature("TO", "O")         # typedef PyObject * (*binaryfunc)(PyObject *, PyObject *);
-ternaryfunc = Signature("OOO", "O")        # typedef PyObject * (*ternaryfunc)(PyObject *, PyObject *, PyObject *);
-iternaryfunc = Signature("TOO", "O")       # typedef PyObject * (*ternaryfunc)(PyObject *, PyObject *, PyObject *);
+powternaryfunc = Signature("OO?", "O")        # typedef PyObject * (*ternaryfunc)(PyObject *, PyObject *, PyObject *);
+ipowternaryfunc = Signature("TO?", "O")       # typedef PyObject * (*ternaryfunc)(PyObject *, PyObject *, PyObject *);
path: Cython/Compiler/TypeSlots.py
text: @user1:
```suggestion
powternaryfunc = Signature("OO?", "O")     # typedef PyObject * (*ternaryfunc)(PyObject *, PyObject *, PyObject *);
ipowternaryfunc = Signature("TO?", "O")    # typedef PyObject * (*ternaryfunc)(PyObject *, PyObject *, PyObject *);
```