Repository: pyjanitor-devs/pyjanitor

Issue:
[ENH] Add a make_clean_names function for cleaning dataframe values
# Brief Description

<!-- Please provide a brief description of what you'd like to propose. -->

Hi! I would like to propose the creation of a function like "**janitor::make_clean_names()**". This function exists on janitor in R.
It's an amazing function because we can normalize any text in a apply and lambda function.
Thanks in advance.

# Example

<!-- One of the selling points of pyjanitor is the API. Hence, we guard the API very carefully, and want to
make sure that it is accessible and understandable to many people. Please provide a few examples of what the API
of the new function you're proposing will look like. We have provided an example that you should modify. -->

Below there is an example of the function application in a R environment.

```r
> janitor::make_clean_names("Abçdê fgí j")
[1] "abcde_fgi_j"

> janitor::make_clean_names("(--Abc\\dÊ fg#$klmnö)")
[1] "abc_d_e_fg_number_klmno"
```



Discussion:
 [clean_names](https://pyjanitor-devs.github.io/pyjanitor/api/functions/#janitor.functions.clean_names.clean_names) exists already
I tried clean_names, but its apply only over column names. I'm looking for "make_clean_names()" because this function apply over any text in the rows.
This is a neat suggestion, @alfredojoseneto! Thanks for suggesting it. 

I think having the functionality ported over is a great idea, and I'm in favour of it.

At the same time, I'd like to consider the naming of the function itself a bit more carefully. Usually, I'd prefer that we just repeat the R function name in `pyjanitor`, but in this case I'm also seeing the potential for namespace confusion, and therefore [pytorch-like entropy](https://twitter.com/karpathy/status/1478564065660207105) in the codebase.

I see a few options. Here's me thinking them out aloud:

1. Modify the existing `clean_names` to provide an argument that declares whether it should be applied across: dataframe index, dataframe column, or dataframe values (hence corresponding to the natural anatomy of a pandas Dataframe)
2. Provide a new text cleaning function, named `clean_text`, that can be called as a dataframe class method. It can use the same underlying functions as `clean_names`, but it strictly applies to just the dataframe values.

Modifying `clean_names`' behaviour seems okay to me, though now we're generalizing it to `clean_text` rather than it being merely a `clean_name` (i.e. for column names or row names), where `name` covers a stricter subset of items than `text`. Hence the idea of providing `clean_text` as the function name instead of `clean_names`. I figure that as long as the documentation of `clean_text` is done right, and that it is placed proximally close to `clean_names` in the codebase, then given their similarity in functionality, long-term maintenance should be okay.

Having said all of that, what are your thoughts, @alfredojoseneto and @samukweku? @alfredojoseneto, we green-light the idea on the dev team, would you be open to contributing that functionality to the pyjanitor codebase? I'd be happy to guide you through it; there may be a few rounds of back-and-forth to shape it right, but I think that'd be an awesome contribution and I'd love for you to be able to partake in the development process, if that's something you desire!

Resolving patch:
diff --git a/janitor/functions/clean_names.py b/janitor/functions/clean_names.py
--- a/janitor/functions/clean_names.py
+++ b/janitor/functions/clean_names.py
@@ -1,5 +1,7 @@
 """Functions for cleaning columns names."""
-import re
+from janitor.utils import deprecated_alias
+from janitor.functions.utils import get_index_labels, _is_str_or_cat
+from pandas.api.types import is_scalar
 from typing import Hashable, Optional, Union
 import pandas as pd
 import pandas_flavor as pf
@@ -9,17 +11,20 @@
 
 
 @pf.register_dataframe_method
+@deprecated_alias(preserve_original_columns="preserve_original_labels")
 def clean_names(
     df: pd.DataFrame,
+    axis: Union[str, None] = "columns",
+    column_names: Union[str, list] = None,
     strip_underscores: Optional[Union[str, bool]] = None,
     case_type: str = "lower",
     remove_special: bool = False,
     strip_accents: bool = True,
-    preserve_original_columns: bool = True,
+    preserve_original_labels: bool = True,
     enforce_string: bool = True,
     truncate_limit: int = None,
 ) -> pd.DataFrame:
-    """Clean column names.
+    """Clean column/index names. It can also be applied to column values.
 
     Takes all column names, converts them to lowercase,
     then replaces all spaces with underscores.
@@ -55,8 +60,20 @@ def clean_names(
         1      1           1        1
         2      2           2        2
 
+    !!! summary "Version Changed"
+
+        - 0.26.0
+             - Added `axis` and `column_names` parameters.
+
     Args:
         df: The pandas DataFrame object.
+        axis: Whether to clean the labels on the index or columns.
+            If `None`, applies to a defined column
+            or columns in `column_names`.
+        column_names: Clean the values in a column.
+            `axis` should be `None`.
+            Column selection is possible using the
+            [`select`][janitor.functions.select.select] syntax.
         strip_underscores: Removes the outer underscores from all
             column names. Default None keeps outer underscores. Values can be
             either 'left', 'right' or 'both' or the respective shorthand 'l',
@@ -70,94 +87,146 @@ def clean_names(
             Only letters, numbers and underscores are preserved.
         strip_accents: Whether or not to remove accents from
             columns names.
-        preserve_original_columns: Preserve original names.
-            This is later retrievable using `df.original_columns`.
+        preserve_original_labels: Preserve original names.
+            This is later retrievable using `df.original_labels`.
+            Applies if `axis` is not None.
         enforce_string: Whether or not to convert all column names
             to string type. Defaults to True, but can be turned off.
             Columns with >1 levels will not be converted by default.
         truncate_limit: Truncates formatted column names to
             the specified length. Default None does not truncate.
 
+    Raises:
+        ValueError: If `axis=None` and `column_names=None`.
+
     Returns:
         A pandas DataFrame.
     """
-    original_column_names = list(df.columns)
-
-    if enforce_string:
-        df = df.rename(columns=str)
-
-    df = df.rename(columns=lambda x: _change_case(x, case_type))
-
-    df = df.rename(columns=_normalize_1)
-
-    if remove_special:
-        df = df.rename(columns=_remove_special)
-
-    if strip_accents:
-        df = df.rename(columns=_strip_accents)
-
-    df = df.rename(columns=lambda x: re.sub("_+", "_", x))  # noqa: PD005
-    df = _strip_underscores(df, strip_underscores)
-
-    df = df.rename(columns=lambda x: x[:truncate_limit])
-
+    if not axis and not column_names:
+        raise ValueError(
+            "Kindly provide an argument to `column_names`, if axis is None."
+        )
+    if axis is None:
+        column_names = get_index_labels(
+            arg=column_names, df=df, axis="columns"
+        )
+        if is_scalar(column_names):
+            column_names = [column_names]
+        df = df.copy()
+        for column_name in column_names:
+            df[column_name] = _clean_names_single_object(
+                obj=df[column_name],
+                enforce_string=enforce_string,
+                case_type=case_type,
+                remove_special=remove_special,
+                strip_accents=strip_accents,
+                strip_underscores=strip_underscores,
+                truncate_limit=truncate_limit,
+            )
+        return df
+
+    assert axis in {"index", "columns"}
+    df = df[:]
+    target_axis = getattr(df, axis)
+    if isinstance(target_axis, pd.MultiIndex):
+        target_axis = [
+            target_axis.get_level_values(number)
+            for number in range(target_axis.nlevels)
+        ]
+        target_axis = [
+            _clean_names_single_object(
+                obj=obj,
+                enforce_string=enforce_string,
+                case_type=case_type,
+                remove_special=remove_special,
+                strip_accents=strip_accents,
+                strip_underscores=strip_underscores,
+                truncate_limit=truncate_limit,
+            )
+            for obj in target_axis
+        ]
+    else:
+        target_axis = _clean_names_single_object(
+            obj=target_axis,
+            enforce_string=enforce_string,
+            case_type=case_type,
+            remove_special=remove_special,
+            strip_accents=strip_accents,
+            strip_underscores=strip_underscores,
+            truncate_limit=truncate_limit,
+        )
     # Store the original column names, if enabled by user
-    if preserve_original_columns:
-        df.__dict__["original_columns"] = original_column_names
+    if preserve_original_labels:
+        df.__dict__["original_labels"] = getattr(df, axis)
+    setattr(df, axis, target_axis)
     return df
 
 
-def _change_case(col: str, case_type: str) -> str:
-    """Change case of a column name."""
-    case_types = ["preserve", "upper", "lower", "snake"]
-    if case_type.lower() not in case_types:
+def _clean_names_single_object(
+    obj: Union[pd.Index, pd.Series],
+    enforce_string,
+    case_type,
+    remove_special,
+    strip_accents,
+    strip_underscores,
+    truncate_limit,
+):
+    """
+    Apply _clean_names on a single pandas object.
+    """
+    if enforce_string and not (_is_str_or_cat(obj)):
+        obj = obj.astype(str)
+    obj = _change_case(obj, case_type)
+    obj = _normalize_1(obj)
+    if remove_special:
+        obj = obj.map(_remove_special)
+    if strip_accents:
+        obj = obj.map(_strip_accents)
+    obj = obj.str.replace(pat="_+", repl="_", regex=True)
+    obj = _strip_underscores_func(obj, strip_underscores=strip_underscores)
+    if truncate_limit:
+        obj = obj.str[:truncate_limit]
+    return obj
+
+
+def _change_case(col: Union[pd.Index, pd.Series], case_type: str) -> str:
+    """Change case of labels in pandas object."""
+    case_types = {"preserve", "upper", "lower", "snake"}
+    case_type = case_type.lower()
+    if case_type not in case_types:
         raise JanitorError(f"case_type must be one of: {case_types}")
-
-    if case_type.lower() != "preserve":
-        if case_type.lower() == "upper":
-            col = col.upper()
-        elif case_type.lower() == "lower":
-            col = col.lower()
-        elif case_type.lower() == "snake":
-            col = _camel2snake(col)
-
-    return col
+    if case_type == "preserve":
+        return col
+    if case_type == "upper":
+        return col.str.upper()
+    if case_type == "lower":
+        return col.str.lower()
+    # Implementation taken from: https://gist.github.com/jaytaylor/3660565
+    # by @jtaylor
+    return (
+        col.str.replace(pat=r"(.)([A-Z][a-z]+)", repl=r"\1_\2", regex=True)
+        .str.replace(pat=r"([a-z0-9])([A-Z])", repl=r"\1_\2", regex=True)
+        .str.lower()
+    )
 
 
-def _remove_special(col_name: Hashable) -> str:
-    """Remove special characters from column name."""
+def _remove_special(label: Hashable) -> str:
+    """Remove special characters from label."""
     return "".join(
-        item for item in str(col_name) if item.isalnum() or "_" in item
+        [item for item in str(label) if item.isalnum() or "_" in item]
     )
 
 
-_underscorer1 = re.compile(r"(.)([A-Z][a-z]+)")
-_underscorer2 = re.compile("([a-z0-9])([A-Z])")
-
-
-def _camel2snake(col_name: str) -> str:
-    """Convert camelcase names to snake case.
-
-    Implementation taken from: https://gist.github.com/jaytaylor/3660565
-    by @jtaylor
-    """
-    subbed = _underscorer1.sub(r"\1_\2", col_name)  # noqa: PD005
-    return _underscorer2.sub(r"\1_\2", subbed).lower()  # noqa: PD005
-
-
-FIXES = [(r"[ /:,?()\.-]", "_"), (r"['’]", ""), (r"[\xa0]", "_")]
-
-
-def _normalize_1(col_name: Hashable) -> str:
-    """Perform normalization of column name."""
-    result = str(col_name)
+def _normalize_1(col: Union[pd.Index, pd.Series]) -> str:
+    """Perform normalization of labels in pandas object."""
+    FIXES = [(r"[ /:,?()\.-]", "_"), (r"['’]", ""), (r"[\xa0]", "_")]
     for search, replace in FIXES:
-        result = re.sub(search, replace, result)  # noqa: PD005
-    return result
+        col = col.str.replace(pat=search, repl=replace, regex=True)
+    return col
 
 
-def _strip_accents(col_name: str) -> str:
-    """Remove accents from a DataFrame column name.
+def _strip_accents(label: Hashable) -> str:
+    """Remove accents from a label.
 
     Inspired from [StackOverflow][so].
 
@@ -165,55 +234,28 @@ def _strip_accents(col_name: str) -> str:
     """  # noqa: E501
 
     return "".join(
-        letter
-        for letter in unicodedata.normalize("NFD", col_name)
-        if not unicodedata.combining(letter)
-    )
-
-
-def _strip_underscores(
-    df: pd.DataFrame, strip_underscores: Union[str, bool] = None
-) -> pd.DataFrame:
-    """
-    Strip underscores from DataFrames column names.
-
-    Underscores can be stripped from the beginning, end or both.
-
-    Examples:
-        ```python
-        df = _strip_underscores(df, strip_underscores='left')
-        ```
-
-    Args:
-        df: The pandas DataFrame object.
-        strip_underscores: Removes the outer underscores from all
-            column names. Default `None` keeps outer underscores. Values can be
-            either `'left'`, `'right'` or `'both'` or the respective shorthand
-            `'l'`, `'r'` and `True`.
-
-    Returns:
-        A pandas DataFrame with underscores removed.
-    """
-    df = df.rename(
-        columns=lambda x: _strip_underscores_func(x, strip_underscores)
+        [
+            letter
+            for letter in unicodedata.normalize("NFD", str(label))
+            if not unicodedata.combining(letter)
+        ]
     )
-    return df
 
 
 def _strip_underscores_func(
-    col: str, strip_underscores: Union[str, bool] = None
+    col: Union[pd.Index, pd.Series], strip_underscores: Union[str, bool] = None
 ) -> pd.DataFrame:
-    """Strip underscores from a string."""
-    underscore_options = [None, "left", "right", "both", "l", "r", True]
+    """Strip underscores from a pandas object."""
+    underscore_options = {None, "left", "right", "both", "l", "r", True}
     if strip_underscores not in underscore_options:
         raise JanitorError(
             f"strip_underscores must be one of: {underscore_options}"
         )
 
     if strip_underscores in ["left", "l"]:
-        col = col.lstrip("_")
-    elif strip_underscores in ["right", "r"]:
-        col = col.rstrip("_")
-    elif strip_underscores == "both" or strip_underscores is True:
-        col = col.strip("_")
+        return col.str.lstrip("_")
+    if strip_underscores in ["right", "r"]:
+        return col.str.rstrip("_")
+    if strip_underscores in {True, "both"}:
+        return col.str.strip("_")
     return col


Test patch:
diff --git a/tests/functions/test_clean_names.py b/tests/functions/test_clean_names.py
--- a/tests/functions/test_clean_names.py
+++ b/tests/functions/test_clean_names.py
@@ -49,7 +49,7 @@ def test_clean_names_uppercase(dataframe):
 @pytest.mark.functions
 def test_clean_names_original_columns(dataframe):
     """Tests clean_names `preserve_original_columns` parameter."""
-    df = dataframe.clean_names(preserve_original_columns=True)
+    df = dataframe.clean_names(preserve_original_labels=True)
     expected_columns = [
         "a",
         "Bell__Chart",
@@ -57,7 +57,7 @@ def test_clean_names_original_columns(dataframe):
         "animals@#$%^",
         "cities",
     ]
-    assert set(df.original_columns) == set(expected_columns)
+    assert set(df.original_labels) == set(expected_columns)
 
 
 @pytest.mark.functions
@@ -202,3 +202,27 @@ def test_charac():
     df = df.clean_names(strip_underscores=True, case_type="lower")
 
     assert "current_accountbalance_in_%_of_gdp" in df.columns
+
+
+def test_clean_column_values_error():
+    """Raise if both axis and column_names are None"""
+    raw = pd.DataFrame({"raw": ["Abçdê fgí j"]})
+    with pytest.raises(
+        ValueError,
+        match="Kindly provide an argument to `column_names`, if axis is None.",
+    ):
+        raw.clean_names(axis=None, column_names=None)
+
+
+def test_clean_column_values():
+    """Clean column values"""
+    raw = pd.DataFrame({"raw": ["Abçdê fgí j"]})
+    outcome = raw.clean_names(axis=None, column_names="raw").squeeze()
+    assert outcome == "abcde_fgi_j"
+
+
+def test_clean_names_enforce_str():
+    """Test enforce_strings on non string columns"""
+    df = pd.DataFrame({1: [3, 4], 11: [5, 6]})
+    outcome = df.clean_names().columns.tolist()
+    assert outcome == ["1", "11"]