Repository: pyjanitor-devs/pyjanitor (python)

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"
```


Pull request:
[ENH] clean column values with `clean_names`
# PR Description

Please describe the changes proposed in the pull request:

- clean column values with clean_names
- clean_names supports both index and columns

**This PR resolves #995 .**

<!-- As you go down the PR template, please feel free to delete sections that are irrelevant. -->

# PR Checklist

<!-- This checklist exists for newcomers who are not yet familiar with our requirements. If you are experienced with
the project, please feel free to delete this section. -->

Please ensure that you have done the following:

1. [x] PR in from a fork off your branch. Do not PR from `<your_username>`:`dev`, but rather from `<your_username>`:`<feature-branch_name>`.
<!-- Doing this helps us keep the commit history much cleaner than it would otherwise be. -->
2. [x] If you're not on the contributors list, add yourself to `AUTHORS.md`.
<!-- We'd like to acknowledge your contributions! -->
3. [x] Add a line to `CHANGELOG.md` under the latest version header (i.e. the one that is "on deck") describing the contribution.
    - Do use some discretion here; if there are multiple PRs that are related, keep them in a single line.

# Automatic checks

There will be automatic checks run on the PR. These include:

- Building a preview of the docs on Netlify
- Automatically linting the code
- Making sure the code is documented
- Making sure that all tests are passed
- Making sure that code coverage doesn't go down.

# Relevant Reviewers

<!-- Finally, please tag relevant maintainers to review. -->

Please tag maintainers to review.

- @ericmjl


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bb78cb5..fa029c7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,7 @@
 # Changelog
 
 ## [Unreleased]
+-   [ENH] `clean_names` can now be applied to column values. Issue #995 @samukweku
 -   [BUG] Fix ImportError - Issue #1285 @samukweku
 
 ## [v0.25.0] - 2023-07-27
diff --git a/janitor/functions/clean_names.py b/janitor/functions/clean_names.py
index d6a1a0d..a27d99a 100644
--- 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 @@ import unicodedata
 
 
 @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
index d152b7d..38c6f88 100644
--- 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"]