Repository: pyjanitor-devs/pyjanitor

Issue:
Add extra functionality to `rename_columns`
# Brief Description

It would be nice if we could extend the  [rename_columns](https://pyjanitor.readthedocs.io/reference/janitor.functions/janitor.rename_columns.html#janitor.rename_columns) method to allow dynamic replacement of columns, beyond passing a dictionary. It will take the same functionality as pandas' [rename](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.rename.html) method, which it is built on, and also be able to work on a subset of columns. If it is acceptable, option of ``kwargs`` would look nice.
# Example API

```python
data = dict(x=[1], y=[2], double_x=[2], double_y=[4])
df = pd.DataFrame(data)

    x  y  double_x  double_y
 0  1  2    2        4
```
Convert all to lower case - we cant do that with [rename_columns](https://pyjanitor.readthedocs.io/reference/janitor.functions/janitor.rename_columns.html#janitor.rename_columns) , but we can with pandas' [rename](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.rename.html):

```python
df.rename(columns = str.upper)

        X	Y	DOUBLE_X	DOUBLE_Y
0	1	2	2	         4
```
Suggested solution would be to add a function argument to [rename_columns](https://pyjanitor.readthedocs.io/reference/janitor.functions/janitor.rename_columns.html#janitor.rename_columns):

```python
# this will apply to all the columns
df.rename_columns(func = str.upper)

        X	Y	DOUBLE_X	DOUBLE_Y
0	1	2	2	         4
```
We can also modify a subset of the columns :

```python

# single column
df.rename(columns=lambda col: col.replace("x", "stuff"))

	stuff	y	double_stuff	double_y
0	 1	2	      2	        4

# proposed equivalent in rename_columns:
df.rename_columns(func = lambda col: col.replace("x", "stuff"))

# changes to multiple columns
df.rename(columns=lambda x: x.upper() if x.startswith("double") else x)

        x	y	DOUBLE_X	DOUBLE_Y
0	1	2	2	         4

# proposed equivalent in rename_columns:
df.rename_columns(func = lambda x: x.upper() if x.startswith("double") else x)
```

And the kwargs option for one-to-one renaming (function should be able to accept kwargs or dicts, with dicts staying as first choice):

```python

df.rename_columns(old_column_name =  "new_column_name")

```

The other extreme would probably be to deprecate `rename_columns`


Discussion:
@samukweku Is this being worked on ? If not, I will pick it up.
@nvamsikrishna05 pls go ahead

Resolving patch:
diff --git a/janitor/functions.py b/janitor/functions.py
--- a/janitor/functions.py
+++ b/janitor/functions.py
@@ -894,14 +894,21 @@ def rename_column(
 
 
 @pf.register_dataframe_method
-def rename_columns(df: pd.DataFrame, new_column_names: Dict) -> pd.DataFrame:
-    """Rename columns in place.
+def rename_columns(
+    df: pd.DataFrame,
+    new_column_names: Union[Dict, None] = None,
+    function: Callable = None,
+) -> pd.DataFrame:
+    """Rename columns.
 
     Functional usage syntax:
 
     .. code-block:: python
 
         df = rename_columns(df, {"old_column_name": "new_column_name"})
+        df = rename_columns(df, function = str.upper)
+        df = rename_columns(df,
+                function = lambda x : x.lower() if x.startswith("double") else x)
 
     Method chaining syntax:
 
@@ -911,17 +918,31 @@ def rename_columns(df: pd.DataFrame, new_column_names: Dict) -> pd.DataFrame:
         import janitor
         df = pd.DataFrame(...).rename_columns({"old_column_name": "new_column_name"})
 
-    This is just syntactic sugar/a convenience function for renaming one column
-    at a time. If you are convinced that there are multiple columns in need of
-    changing, then use the :py:meth:`pandas.DataFrame.rename` method.
+    This is just syntactic sugar/a convenience function for renaming multiple columns
+    at a time. If you need to rename single column,
+    then use the `rename_column` method.
+
+    One of the new_column_names or function are a required parameter.
+    If both are provided then new_column_names takes priority and function
+    is never executed.
 
     :param df: The pandas DataFrame object.
     :param new_column_names: A dictionary of old and new column names.
+    :param function: A function which should be applied to all the columns
     :returns: A pandas DataFrame with renamed columns.
+    :raises ValueError: if both new_column_names and function are None
     """  # noqa: E501
-    check_column(df, list(new_column_names.keys()))
 
-    return df.rename(columns=new_column_names)
+    if new_column_names is None and function is None:
+        raise ValueError(
+            "One of new_column_names or function must be provided"
+        )
+
+    if new_column_names is not None:
+        check_column(df, new_column_names)
+        return df.rename(columns=new_column_names)
+
+    return df.rename(mapper=function, axis="columns")
 
 
 @pf.register_dataframe_method


Test patch:
diff --git a/tests/functions/test_rename_columns.py b/tests/functions/test_rename_columns.py
--- a/tests/functions/test_rename_columns.py
+++ b/tests/functions/test_rename_columns.py
@@ -4,6 +4,10 @@
 
 @pytest.mark.functions
 def test_rename_columns(dataframe):
+    """
+    Tests If rename_columns renames multiple columns based on the
+    dictionary mappings.
+    """
     df = dataframe.clean_names().rename_columns(
         {"a": "index", "bell_chart": "chart"}
     )
@@ -23,3 +27,29 @@ def test_rename_columns_absent_column(dataframe):
         df.clean_names().rename_columns({"a": "index", "bb": "chart"})
 
     assert set(df.columns) == set(dataframe.columns)
+
+
+@pytest.mark.functions
+def test_rename_columns_function(dataframe):
+    """
+    rename_columns should apply the given function for each column name
+    """
+    df = dataframe.clean_names().rename_columns(function=str.upper)
+    assert set(df.columns) == set(
+        ["A", "BELL_CHART", "DECORATED_ELEPHANT", "ANIMALS@#$%^", "CITIES"]
+    )
+
+    assert "a" not in set(df.columns)
+
+
+@pytest.mark.functions
+def test_rename_columns_no_args(dataframe):
+    """
+    rename_columns should throw error when both column_name and function are
+    not provided.
+    """
+    df = dataframe.copy()
+    with pytest.raises(ValueError):
+        df.rename_columns()
+
+    assert set(df.columns) == set(dataframe.columns)