Repository: modin-project/modin (python)

Issue:
BUG: dataframe extensions for some methods (possibly just aliased methods like pow?) are not working
### Modin version checks

- [x] I have checked that this issue has not already been reported.

- [x] I have confirmed this bug exists on the latest released version of Modin.

- [x] I have confirmed this bug exists on the main branch of Modin. (In order to do this you can follow [this guide](https://modin.readthedocs.io/en/stable/getting_started/installation.html#installing-from-the-github-main-branch).)


### Reproducible Example

```python
import modin.pandas as pd
from modin.pandas.api.extensions import register_dataframe_accessor
register_dataframe_accessor("pow")(lambda *args, **kwargs: 4)
result = pd.DataFrame([1]).pow(1)
assert isinstance(result, int)
```

### Issue Description

the override is not working.

### Expected Behavior

In the example, we should override the `pow` behavior for all backends.

### Error Logs

n/a

### Installed Versions

<details>

INSTALLED VERSIONS
------------------
commit                : d512c53e1ea032fba10d2ab1261ffd621940752d
python                : 3.9.21
python-bits           : 64
OS                    : Darwin
OS-release            : 24.3.0
Version               : Darwin Kernel Version 24.3.0: Thu Jan  2 20:24:23 PST 2025; root:xnu-11215.81.4~3/RELEASE_ARM64_T6020
machine               : arm64
processor             : arm
byteorder             : little
LC_ALL                : None
LANG                  : en_US.UTF-8
LOCALE                : en_US.UTF-8

Modin dependencies
------------------
modin                 : 0.32.0+33.gd512c53e
ray                   : 2.40.0
dask                  : 2024.8.0
distributed           : 2024.8.0

pandas dependencies
-------------------
pandas                : 2.2.3
numpy                 : 1.26.4
pytz                  : 2024.2
dateutil              : 2.8.2
pip                   : 24.2
Cython                : None
sphinx                : 5.3.0
IPython               : 8.18.1
adbc-driver-postgresql: None
adbc-driver-sqlite    : None
bs4                   : 4.12.3
blosc                 : None
bottleneck            : None
dataframe-api-compat  : None
fastparquet           : 2024.11.0
fsspec                : 2024.12.0
html5lib              : None
hypothesis            : None
gcsfs                 : None
jinja2                : 3.1.5
lxml.etree            : 5.3.0
matplotlib            : 3.9.4
numba                 : None
numexpr               : 2.10.2
odfpy                 : None
openpyxl              : 3.1.5
pandas_gbq            : 0.26.1
psycopg2              : 2.9.10
pymysql               : None
pyarrow               : 19.0.0
pyreadstat            : None
pytest                : 8.3.4
python-calamine       : None
pyxlsb                : None
s3fs                  : 2024.12.0
scipy                 : 1.13.1
sqlalchemy            : 2.0.37
tables                : 3.9.2
tabulate              : 0.9.0
xarray                : 2024.7.0
xlrd                  : 2.0.1
xlsxwriter            : None
zstandard             : None
tzdata                : 2024.2
qtpy                  : None
pyqt5                 : None


</details>


Pull request description:
FIX-#7495: Separate extensions for aliases.
Prior to this commit, synonyms like `DataFrame.__pow__` and `DataFrame.pow` would use the same extension set, but would address it with different names. That would cause confusing behavior. Now we define separate wrappers for each alias, so that each alias has its own set of extensions and can draw from that set of extensions appropriately.

- [x] first commit message and PR title follow format outlined [here](https://modin.readthedocs.io/en/latest/development/contributing.html#commit-message-formatting)
  > **_NOTE:_**  If you edit the PR title to match this format, you need to add another commit (even if it's empty) or amend your last commit for the CI job that checks the PR title to pick up the new PR title.
- [x] passes `flake8 modin/ asv_bench/benchmarks scripts/doc_checker.py`
- [x] passes `black --check modin/ asv_bench/benchmarks scripts/doc_checker.py`
- [x] signed commit with `git commit -s` <!-- you can amend your commit with a signature via `git commit -amend -s` -->
- [x] Resolves #7495
- [x] tests added and passing
- [x] module layout described at `docs/development/architecture.rst` is up-to-date <!-- if you have added, renamed or removed files or directories please update the documentation accordingly -->


Patch:
diff --git a/modin/core/storage_formats/pandas/query_compiler_caster.py b/modin/core/storage_formats/pandas/query_compiler_caster.py
index 6b05aa9a..936bac20 100644
--- a/modin/core/storage_formats/pandas/query_compiler_caster.py
+++ b/modin/core/storage_formats/pandas/query_compiler_caster.py
@@ -24,7 +24,7 @@ import inspect
 from abc import ABC, abstractmethod
 from collections import defaultdict, namedtuple
 from types import FunctionType, MethodType
-from typing import Any, Callable, Dict, Optional, Tuple, TypeVar, Union, ValuesView
+from typing import Any, Dict, Optional, Tuple, TypeVar, Union, ValuesView
 
 from pandas.core.indexes.frozen import FrozenList
 from typing_extensions import Self
@@ -182,6 +182,42 @@ def visit_nested_args(arguments, fn: callable):
     return arguments
 
 
+def _assert_casting_functions_wrap_same_implementation(
+    m1: callable, m2: callable
+) -> None:
+    """
+    Assert that two casting wrappers wrap the same implementation.
+
+    Parameters
+    ----------
+    m1 : callable
+        The first casting wrapper.
+    m2 : callable
+        The second casting wrapper.
+
+    Raises
+    ------
+    AssertionError
+        If the two casting wrappers wrap different implementations.
+    """
+    assert (
+        # For cases like (m1=Series.agg, m2=Series.aggregate), where Series
+        # defines its own method and aliases it, the two wrapped methods
+        # are the same.
+        m2._wrapped_method_for_casting is m1._wrapped_method_for_casting
+        # For cases like (m1=Series.kurt, m2=Series.kurtosis), where Series
+        # inherits both kurt and kurtosis from BasePandasDataset but does
+        # not define its own implementation of either,
+        # Series.kurt._wrapped_method_for_casting points to
+        # BasePandasDataset.kurt, which is not the same as
+        # BasePandasDataset.kurtosis. In that case, we need to go one level
+        # deeper to compare the wrapped methods of the two aliases of
+        # BasePandasDataset.
+        or m2._wrapped_method_for_casting._wrapped_method_for_casting
+        is m1._wrapped_method_for_casting._wrapped_method_for_casting
+    )
+
+
 def apply_argument_cast_to_class(klass: type) -> type:
     """
     Apply argument casting to all functions in a class.
@@ -202,64 +238,52 @@ def apply_argument_cast_to_class(klass: type) -> type:
     for key in current_class_attrs:
         all_attrs[key] = current_class_attrs[key]
 
-    # We want to avoid wrapping synonyms like __add__() and add() with
-    # different wrappers, so keep a dict mapping methods we've wrapped
-    # to their wrapped versions.
-    already_seen_to_wrapped: dict[Callable, Callable] = {}
-
     for attr_name, attr_value in all_attrs.items():
-        if isinstance(attr_value, (FunctionType, classmethod, staticmethod)):
-            implementation_function = (
-                attr_value.__func__
-                if isinstance(attr_value, (classmethod, staticmethod))
-                else attr_value
-            )
-
-            if attr_name not in klass._extensions[None]:
-                # Register the original implementation as the default
-                # extension. We fall back to this implementation if the
-                # object's backend does not have an implementation for this
-                # method.
-                klass._extensions[None][attr_name] = implementation_function
-
-            if attr_value in already_seen_to_wrapped:
-                setattr(
-                    klass,
-                    attr_name,
-                    already_seen_to_wrapped[attr_value],
-                )
-            elif attr_name not in _NON_EXTENDABLE_ATTRIBUTES:
-                casting_implementation = wrap_function_in_argument_caster(
-                    f=implementation_function,
-                    wrapping_function_type=(
-                        classmethod
-                        if isinstance(attr_value, classmethod)
-                        else (
-                            staticmethod
-                            if isinstance(attr_value, staticmethod)
-                            else MethodType
-                        )
-                    ),
-                    extensions=klass._extensions,
-                    name=attr_name,
-                )
-                wrapped = (
-                    classmethod(casting_implementation)
-                    if isinstance(attr_value, classmethod)
-                    else (
-                        staticmethod(casting_implementation)
-                        if isinstance(attr_value, staticmethod)
-                        else casting_implementation
-                    )
+        if attr_name in _NON_EXTENDABLE_ATTRIBUTES or not isinstance(
+            attr_value, (FunctionType, classmethod, staticmethod)
+        ):
+            continue
+
+        implementation_function = (
+            attr_value.__func__
+            if isinstance(attr_value, (classmethod, staticmethod))
+            else attr_value
+        )
+        if attr_name not in klass._extensions[None]:
+            # Register the original implementation as the default
+            # extension. We fall back to this implementation if the
+            # object's backend does not have an implementation for this
+            # method.
+            klass._extensions[None][attr_name] = implementation_function
+
+        casting_implementation = wrap_function_in_argument_caster(
+            f=implementation_function,
+            wrapping_function_type=(
+                classmethod
+                if isinstance(attr_value, classmethod)
+                else (
+                    staticmethod if isinstance(attr_value, staticmethod) else MethodType
                 )
-                if attr_name not in klass.__dict__:
-                    # If this class's method comes from a superclass (i.e.
-                    # it's not in klass.__dict__), mark it so that
-                    # modin.utils._inherit_docstrings knows that the method
-                    # must get its docstrings from its superclass.
-                    wrapped._wrapped_superclass_method = attr_value
-                setattr(klass, attr_name, wrapped)
-                already_seen_to_wrapped[attr_value] = wrapped
+            ),
+            extensions=klass._extensions,
+            name=attr_name,
+        )
+        wrapped = (
+            classmethod(casting_implementation)
+            if isinstance(attr_value, classmethod)
+            else (
+                staticmethod(casting_implementation)
+                if isinstance(attr_value, staticmethod)
+                else casting_implementation
+            )
+        )
+        if attr_name not in klass.__dict__:
+            # If this class's method comes from a superclass (i.e.
+            # it's not in klass.__dict__), mark it so that
+            # modin.utils._inherit_docstrings knows that the method
+            # must get its docstrings from its superclass.
+            wrapped._wrapped_superclass_method = attr_value
+        setattr(klass, attr_name, wrapped)
 
     return klass
 
@@ -401,6 +425,7 @@ def wrap_function_in_argument_caster(
                 new_castable._copy_into(original_castable)
         return result
 
+    f_with_argument_casting._wrapped_method_for_casting = f
     return f_with_argument_casting
 
 


Test patch:
diff --git a/modin/tests/pandas/dataframe/test_binary.py b/modin/tests/pandas/dataframe/test_binary.py
index c1f2fdea..72429126 100644
--- a/modin/tests/pandas/dataframe/test_binary.py
+++ b/modin/tests/pandas/dataframe/test_binary.py
@@ -21,6 +21,9 @@ from modin.config import NPartitions, StorageFormat
 from modin.core.dataframe.pandas.partitioning.axis_partition import (
     PandasDataframeAxisPartition,
 )
+from modin.core.storage_formats.pandas.query_compiler_caster import (
+    _assert_casting_functions_wrap_same_implementation,
+)
 from modin.tests.pandas.utils import (
     CustomIntegerForAddition,
     NonCommutativeMultiplyInteger,
@@ -186,7 +189,9 @@ def test_math_functions_level(op):
     ],
 )
 def test_math_alias(math_op, alias):
-    assert getattr(pd.DataFrame, math_op) == getattr(pd.DataFrame, alias)
+    _assert_casting_functions_wrap_same_implementation(
+        getattr(pd.DataFrame, math_op), getattr(pd.DataFrame, alias)
+    )
 
 
 @pytest.mark.parametrize("other", ["as_left", 4, 4.0, "a"])
diff --git a/modin/tests/pandas/dataframe/test_udf.py b/modin/tests/pandas/dataframe/test_udf.py
index 0be64738..fe33b0de 100644
--- a/modin/tests/pandas/dataframe/test_udf.py
+++ b/modin/tests/pandas/dataframe/test_udf.py
@@ -19,6 +19,9 @@ from pandas.core.dtypes.common import is_list_like
 
 import modin.pandas as pd
 from modin.config import MinRowPartitionSize, NPartitions
+from modin.core.storage_formats.pandas.query_compiler_caster import (
+    _assert_casting_functions_wrap_same_implementation,
+)
 from modin.tests.pandas.utils import (
     agg_func_except_keys,
     agg_func_except_values,
@@ -115,7 +118,9 @@ def test_agg_apply_axis_names(axis, func, op, request):
 
 
 def test_aggregate_alias():
-    assert pd.DataFrame.agg == pd.DataFrame.aggregate
+    _assert_casting_functions_wrap_same_implementation(
+        pd.DataFrame.agg, pd.DataFrame.aggregate
+    )
 
 
 def test_aggregate_error_checking():
diff --git a/modin/tests/pandas/extensions/test_dataframe_extensions.py b/modin/tests/pandas/extensions/test_dataframe_extensions.py
index 2cb09ee7..24a43faa 100644
--- a/modin/tests/pandas/extensions/test_dataframe_extensions.py
+++ b/modin/tests/pandas/extensions/test_dataframe_extensions.py
@@ -69,6 +69,33 @@ def test_dataframe_extension_overrides_existing_method(Backend1):
     assert df.set_backend(Backend1).sort_values().iloc[0, 0] == 3
 
 
+@pytest.mark.parametrize(
+    "method_name",
+    [
+        "pow",
+        "__pow__",
+        "__ipow__",
+    ],
+)
+def test_dataframe_extension_overrides_pow_github_issue_7495(method_name):
+    register_dataframe_accessor(method_name, backend="Pandas")(
+        lambda *args, **kwargs: 4
+    )
+    assert getattr(pd.DataFrame([1]).set_backend("Pandas"), method_name)() == 4
+
+
+def test_override_pow_and__pow__to_different_implementations():
+    register_dataframe_accessor("pow", backend="Pandas")(
+        lambda *args, **kwargs: "pow_result"
+    )
+    register_dataframe_accessor("__pow__", backend="Pandas")(
+        lambda *args, **kwargs: "__pow___result"
+    )
+    df = pd.DataFrame([1]).set_backend("pandas")
+    assert df.pow() == "pow_result"
+    assert df.__pow__() == "__pow___result"
+
+
 def test_dataframe_extension_method_uses_superclass_method(Backend1):
     df = pd.DataFrame([3, 2, 1])
     assert df.sort_values(0).iloc[0, 0] == 1
diff --git a/modin/tests/pandas/test_series.py b/modin/tests/pandas/test_series.py
index 633570ad..8b7a16a2 100644
--- a/modin/tests/pandas/test_series.py
+++ b/modin/tests/pandas/test_series.py
@@ -31,6 +31,9 @@ from pandas.errors import PerformanceWarning, SpecificationError
 
 import modin.pandas as pd
 from modin.config import Engine, NPartitions, StorageFormat
+from modin.core.storage_formats.pandas.query_compiler_caster import (
+    _assert_casting_functions_wrap_same_implementation,
+)
 from modin.pandas.io import to_pandas
 from modin.tests.test_utils import (
     current_execution_is_native,
@@ -764,7 +767,9 @@ def test_add_custom_class():
 
 def test_aggregate_alias():
     # It's optimization. If failed, Series.agg should be tested explicitly
-    assert pd.Series.aggregate == pd.Series.agg
+    _assert_casting_functions_wrap_same_implementation(
+        pd.Series.aggregate, pd.Series.agg
+    )
 
 
 @pytest.mark.parametrize("data", test_data_values, ids=test_data_keys)
@@ -2373,7 +2378,9 @@ def test_keys(data):
 def test_kurtosis_alias():
     # It's optimization. If failed, Series.kurt should be tested explicitly
     # in tests: `test_kurt_kurtosis`, `test_kurt_kurtosis_level`.
-    assert pd.Series.kurt == pd.Series.kurtosis
+    _assert_casting_functions_wrap_same_implementation(
+        pd.Series.kurt, pd.Series.kurtosis
+    )
 
 
 @pytest.mark.parametrize("axis", [0, 1])
@@ -2783,7 +2790,9 @@ def test_pow(data):
 
 
 def test_product_alias():
-    assert pd.Series.prod == pd.Series.product
+    _assert_casting_functions_wrap_same_implementation(
+        pd.Series.prod, pd.Series.product
+    )
 
 
 @pytest.mark.parametrize("axis", [0, 1])