Repository: modin-project/modin
Pull request: FIX-#2405: Make sure named aggregation work for Series objects
<!--
Thank you for your contribution!
Please review the contributing docs: https://modin.readthedocs.io/en/latest/development/contributing.html
if you have questions about contributing.
-->

## What do these changes do?

<!-- Please give a short brief about these changes. -->

- [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 #2405 <!-- issue must be created for each patch -->
- [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 -->


Problem statement:
Named aggregation doesn't work with Series objects
### System information
- **OS Platform and Distribution (e.g., Linux Ubuntu 16.04)**:

Ubuntu 20.04

- **Modin version** (`modin.__version__`):

0.8.2.1+6.g2b0b755

- **Python version**:

Python 3.7.8

- **Code we can use to reproduce**:

```python
import modin.pandas as pd

s1 = pd.Series([10, 10, 10, 1, 1, 1, 2, 3])
s1.name = "data"

print(s1)
print(type(s1))
gb = s1.groupby(level=0)
print(gb.groups)
s2 = gb.agg(result=("max"))
print(s2)
print(type(s2))
```

<!--
You can obtain the Modin version with

python -c "import modin; print(modin.__version__)"
-->

### Describe the problem
<!-- Describe the problem clearly here. -->

On Modin this code throws exception from Pandas `reconstruct_func` because it doesn't accept such arguments.
```
Traceback (most recent call last):
  File "groupby_test24.py", line 10, in <module>
    s2 = gb.agg(result=("max"))
  File "/localdisk/gashiman/modin/modin/pandas/groupby.py", line 376, in aggregate
    func, **kwargs
  File "/localdisk/gashiman/modin/modin/pandas/groupby.py", line 364, in _reconstruct_func
    func, **kwargs
  File "/localdisk/gashiman/miniconda3/envs/modin_on_omnisci/lib/python3.7/site-packages/pandas/core/aggregation.py", line 86, in reconstruct_func
    raise TypeError("Must provide 'func' or tuples of '(column, aggfunc).")
TypeError: Must provide 'func' or tuples of '(column, aggfunc).
```

Hints from issue discussion:
I am able to reproduce this bug on the latest master.

Commit message: address review comments

Signed-off-by: Anatoly Myachev <anatoly.myachev@intel.com>

Patch to review:
diff --git a/modin/pandas/groupby.py b/modin/pandas/groupby.py
index 7f6f9193c26..7fd21d94d80 100644
--- a/modin/pandas/groupby.py
+++ b/modin/pandas/groupby.py
@@ -1924,8 +1924,47 @@ def nsmallest(self, n=5, keep="first"):
             )
         )
 
+    def _validate_func_kwargs(self, kwargs: dict):
+        """
+        Validate types of user-provided "named aggregation" kwargs.
+
+        Parameters
+        ----------
+        kwargs : dict
+
+        Returns
+        -------
+        columns : List[str]
+            List of user-provided keys.
+        funcs : List[Union[str, callable[...,Any]]]
+            List of user-provided aggfuncs.
+
+        Raises
+        ------
+        `TypeError` is raised if aggfunc is not `str` or callable.
+
+        Notes
+        -----
+        Copied from pandas.
+        """
+        tuple_given_message = "func is expected but received {} in **kwargs."
+        columns = list(kwargs)
+        funcs = []
+        for col_func in kwargs.values():
+            if not (isinstance(col_func, str) or callable(col_func)):
+                raise TypeError(tuple_given_message.format(type(col_func).__name__))
+            funcs.append(col_func)
+        if not columns:
+            no_arg_message = "Must provide 'func' or named aggregation **kwargs."
+            raise TypeError(no_arg_message)
+        return columns, funcs
+
     def aggregate(self, func=None, *args, engine=None, engine_kwargs=None, **kwargs):
         engine_default = engine is None and engine_kwargs is None
+        # if func is None, will switch to user-provided "named aggregation" kwargs
+        if func_is_none := func is None:
+            columns, func = self._validate_func_kwargs(kwargs)
+            kwargs = {}
         if isinstance(func, dict) and engine_default:
             raise SpecificationError("nested renamer is not supported")
         elif is_list_like(func) and engine_default:
@@ -1946,6 +1985,8 @@ def aggregate(self, func=None, *args, engine=None, engine_kwargs=None, **kwargs)
             # because there is no need to identify which original column's aggregation
             # the new column represents. alternatively we could give the query compiler
             # a hint that it's for a series, not a dataframe.
+            if func_is_none:
+                return result.set_axis(labels=columns, axis=1, copy=False)
             return result.set_axis(
                 labels=self._try_get_str_func(func), axis=1, copy=False
             )
diff --git a/modin/pandas/test/test_groupby.py b/modin/pandas/test/test_groupby.py
index c28ccad1832..cc410e3138a 100644
--- a/modin/pandas/test/test_groupby.py
+++ b/modin/pandas/test/test_groupby.py
@@ -40,6 +40,7 @@
 from .utils import (
     check_df_columns_have_nans,
     create_test_dfs,
+    create_test_series,
     default_to_pandas_ignore_string,
     df_equals,
     dict_equals,
@@ -2989,6 +2990,13 @@ def test_groupby_apply_series_result(modify_config):
     )
 
 
+def test_groupby_named_aggregation():
+    modin_ser, pandas_ser = create_test_series([10, 10, 10, 1, 1, 1, 2, 3], name="data")
+    eval_general(
+        modin_ser, pandas_ser, lambda ser: ser.groupby(level=0).agg(result=("max"))
+    )
+
+
 ### TEST GROUPBY WARNINGS ###
 
 
diff --git a/modin/pandas/test/utils.py b/modin/pandas/test/utils.py
index d8496c8717c..4452c173fed 100644
--- a/modin/pandas/test/utils.py
+++ b/modin/pandas/test/utils.py
@@ -1033,6 +1033,11 @@ def create_test_dfs(*args, **kwargs):
     )
 
 
+def create_test_series(*args, **kwargs):
+    post_fn = kwargs.pop("post_fn", lambda df: df)
+    return map(post_fn, [pd.Series(*args, **kwargs), pandas.Series(*args, **kwargs)])
+
+
 def generate_dfs():
     df = pandas.DataFrame(
         {


Code review comments:
diff_hunk: @@ -1924,8 +1924,47 @@ def nsmallest(self, n=5, keep="first"):
             )
         )
 
+    def _validate_func_kwargs(self, kwargs: dict):
+        """
+        Validate types of user-provided "named aggregation" kwargs.
+
+        Parameters
+        ----------
+        kwargs : dict
+
+        Returns
+        -------
+        columns : List[str]
+            List of user-provided keys.
+        funcs : List[Union[str, callable[...,Any]]]
+            List of user-provided aggfuncs.
+
+        Raises
+        ------
+        `TypeError` is raised if aggfunc is not `str` or callable.
+
+        Notes
+        -----
+        Copied from pandas.
+        """
+        tuple_given_message = "func is expected but received {} in **kwargs."
+        columns = list(kwargs)
+        funcs = []
+        for col_func in kwargs.values():
+            if not (isinstance(col_func, str) or callable(col_func)):
+                raise TypeError(tuple_given_message.format(type(col_func).__name__))
path: modin/pandas/groupby.py
text: @user1:
```suggestion
                raise TypeError(f"func is expected but received {type(col_func).__name__} in **kwargs.")
```

diff_hunk: @@ -1924,8 +1924,47 @@ def nsmallest(self, n=5, keep="first"):
             )
         )
 
+    def _validate_func_kwargs(self, kwargs: dict):
+        """
+        Validate types of user-provided "named aggregation" kwargs.
+
+        Parameters
+        ----------
+        kwargs : dict
+
+        Returns
+        -------
+        columns : List[str]
+            List of user-provided keys.
+        funcs : List[Union[str, callable[...,Any]]]
+            List of user-provided aggfuncs.
+
+        Raises
+        ------
+        `TypeError` is raised if aggfunc is not `str` or callable.
+
+        Notes
+        -----
+        Copied from pandas.
+        """
+        tuple_given_message = "func is expected but received {} in **kwargs."
path: modin/pandas/groupby.py
text: @user1:
```suggestion
```

diff_hunk: @@ -1924,8 +1924,47 @@ def nsmallest(self, n=5, keep="first"):
             )
         )
 
+    def _validate_func_kwargs(self, kwargs: dict):
+        """
+        Validate types of user-provided "named aggregation" kwargs.
+
+        Parameters
+        ----------
+        kwargs : dict
+
+        Returns
+        -------
+        columns : List[str]
+            List of user-provided keys.
+        funcs : List[Union[str, callable[...,Any]]]
+            List of user-provided aggfuncs.
+
+        Raises
+        ------
+        `TypeError` is raised if aggfunc is not `str` or callable.
+
+        Notes
+        -----
+        Copied from pandas.
+        """
+        tuple_given_message = "func is expected but received {} in **kwargs."
+        columns = list(kwargs)
+        funcs = []
+        for col_func in kwargs.values():
+            if not (isinstance(col_func, str) or callable(col_func)):
+                raise TypeError(tuple_given_message.format(type(col_func).__name__))
+            funcs.append(col_func)
+        if not columns:
+            no_arg_message = "Must provide 'func' or named aggregation **kwargs."
+            raise TypeError(no_arg_message)
path: modin/pandas/groupby.py
text: @user1:
```suggestion
            raise TypeError("Must provide 'func' or named aggregation **kwargs.")
```