Repository: pyjanitor-devs/pyjanitor

Issue:
[ENH] Add ability to convert CamelCase to snake_case by default in clean_names
# Brief Description

`clean_names()` right now does not convert names from camel case to snake case. I think it should, and in the spirit of being opinionated, it should be enabled by default.

# Example API

The current function signature is:

```python
def clean_names(df, strip_underscores, case_type: str = 'lower', ...):
```

I would propose changing case_type to default to `snake`, rather than `lower`.

```python
def clean_names(df, strip_underscores, case_type: str = 'snake', ...):
```

# Notes

Some Googling around reveals the following implementations for CamelCase to snake_case conversion:

- [A GitHub gist](https://gist.github.com/jaytaylor/3660565)
- [StackOverflow post](https://gist.github.com/jaytaylor/3660565)

If the implementation is copied over from these two sources, I would ensure that the original code is properly referenced with at least a URL pointing to the original in the docstring. 


Discussion:

Resolving patch:
diff --git a/janitor/functions.py b/janitor/functions.py
--- a/janitor/functions.py
+++ b/janitor/functions.py
@@ -160,8 +160,10 @@ def clean_names(
         either 'left', 'right' or 'both' or the respective shorthand 'l', 'r'
         and True.
     :param case_type: (optional) Whether to make columns lower or uppercase.
-        Current case may be preserved with 'preserve'. Default 'lower'
-        makes all characters lowercase.
+        Current case may be preserved with 'preserve',
+        while snake case conversion (from CamelCase or camelCase only)
+        can be turned on using "snake".
+        Default 'lower' makes all characters lowercase.
     :param remove_special: (optional) Remove special characters from columns.
         Only letters, numbers and underscores are preserved.
     :returns: A pandas DataFrame.
@@ -170,11 +172,11 @@ def clean_names(
     """
     original_column_names = list(df.columns)
 
-    assert case_type.lower() in {
-        "preserve",
-        "upper",
-        "lower",
-    }, "case_type argument must be one of ('preserve', 'upper', 'lower')"
+    case_types = {"preserve", "upper", "lower", "snake"}
+
+    assert (
+        case_type.lower() in case_types
+    ), f"case_type argument must be one of {case_types}"
 
     if case_type.lower() != "preserve":
         if case_type.lower() == "upper":
@@ -183,10 +185,10 @@ def clean_names(
         elif case_type.lower() == "lower":
             df = df.rename(columns=lambda x: x.lower())
 
-    df = df.rename(columns=_normalize_1)
+        elif case_type.lower() == "snake":
+            df = df.rename(columns=_camel2snake)
 
-    def _remove_special(col):
-        return "".join(item for item in col if item.isalnum() or "_" in item)
+    df = df.rename(columns=_normalize_1)
 
     if remove_special:
         df = df.rename(columns=_remove_special)
@@ -203,6 +205,27 @@ def _remove_special(col):
     return df
 
 
+def _remove_special(col_name):
+    """Remove special characters from column name."""
+    return "".join(item for item in col_name 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)
+    return _underscorer2.sub(r"\1_\2", subbed).lower()
+
+
 FIXES = [(r"[ /:,?()\.-]", "_"), (r"['’]", "")]
 
 


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
@@ -131,3 +131,32 @@ def test_clean_names_preserve_case_true(multiindex_dataframe):
 
     expected_columns = pd.MultiIndex(levels=levels, codes=codes)
     assert set(df.columns) == set(expected_columns)
+
+
+@pytest.mark.functions
+@given(df=df_strategy())
+def test_clean_names_camelcase_to_snake(df):
+    df = (
+        df.select_columns(["a"])
+        .rename_column("a", "AColumnName")
+        .clean_names(case_type="snake")
+    )
+    assert list(df.columns) == ["a_column_name"]
+
+
+@pytest.mark.functions
+def test_clean_names_camelcase_to_snake(dataframe):
+    df = (
+        dataframe.select_columns(["a", "Bell__Chart", "decorated-elephant"])
+        .rename_column("a", "snakesOnAPlane")
+        .rename_column("Bell__Chart", "SnakesOnAPlane2")
+        .rename_column("decorated-elephant", "snakes_on_a_plane3")
+        .clean_names(
+            case_type="snake", strip_underscores=True, remove_special=True
+        )
+    )
+    assert list(df.columns) == [
+        "snakes_on_a_plane",
+        "snakes_on_a_plane2",
+        "snakes_on_a_plane3",
+    ]