Repository: pyjanitor-devs/pyjanitor

Issue:
[ENH] Wrap pandas string methods
# Brief Description

One thing that might be nice is to wrap all of the string methods such that they are now method-chainable.

If this could be done programmatically, that would be awesome. Otherwise, manually wrapping ones of interest, and building up a submodule of string method operations, would be a superb alternative.

# Example API

The example API can be found in [this notebook](https://github.com/ericmjl/pyjanitor/blob/dev/examples/notebooks/tidytuesday_20190423.ipynb).


Discussion:
What about this?

Instead of:

```python
@pf.register_dataframe_method
def str_join(df, column_name: str, sep: str, *args, **kwargs):
    """
    Wrapper around `df.str.join`
    Joins items in a list.    
    """

    df[column_name] = df[column_name].str.join(sep)
    return df
```

Make the function generic:

```python
@pf.register_dataframe_method
def str(df, method_type, column, *arg, **kwargs):
    df[column_name] = getattr(df[column_name].str, method_type)(sep)
    return df

df.str('join', 'column_name', sep=',')
```

I've been looking for a way to make the function name generic, too, (i.e. so we could use `df.str_join`
and in pyjanitor the function name would be something like `df.str_` + `method_name`) but that looks like you have to use `setattr` within a `class` and might not work with `@pf.register_dataframe_method`.
@szuckerman @ericmjl I would like o work on this, what testing strategy do you think i should take?

Resolving patch:
diff --git a/janitor/functions.py b/janitor/functions.py
--- a/janitor/functions.py
+++ b/janitor/functions.py
@@ -1,6 +1,7 @@
 """ General purpose data cleaning functions. """
 
 import datetime as dt
+import inspect
 import re
 import unicodedata
 import warnings
@@ -1083,7 +1084,9 @@ def deconcatenate_column(
         df_deconcat = df[column_name].str.split(sep, expand=True)
     else:
         df_deconcat = pd.DataFrame(
-            df[column_name].to_list(), columns=new_column_names, index=df.index
+            df[column_name].to_list(),
+            columns=new_column_names,
+            index=df.index,
         )
 
     if preserve_position:
@@ -4001,3 +4004,103 @@ def expand_grid(
     dfs, dicts = _check_instance(others)
 
     return _grid_computation(dfs, dicts)
+
+
+@pf.register_dataframe_method
+def process_text(
+    df: pd.DataFrame,
+    column: str,
+    string_function: str,
+    *args: str,
+    **kwargs: str,
+) -> pd.DataFrame:
+    """
+    Apply a Pandas string method to an existing column and return a dataframe.
+
+    This function aims to make string cleaning easy, while chaining,
+    by simply passing the string method name to the ``process_text`` function.
+    Note that this modifies an existing column,
+    and should not be used to create a new column.
+    A list of all the string methods in Pandas can be accessed here:
+    https://pandas.pydata.org/docs/user_guide/text.html#method-summary.
+
+    Example:
+
+    .. code-block:: python
+
+        import pandas as pd
+        import janitor as jn
+
+        df = pd.DataFrame({"text":["ragnar","sammywemmy","ginger"],
+                           "code" : [1, 2, 3]})
+
+        df.process_text(column = "text", string_function = "lower")
+        # text       |   code
+        # ragnar     |    1
+        # sammywemmy |    2
+        # ginger     |    3
+
+        #For string methods with parameters, simply pass the arguments :
+        df.process_text(
+            column = "text",
+            string_function = "extract",
+            pat = r"(ag)",
+            flags = re.IGNORECASE
+            )
+
+        # text |   code
+        # ag   |    1
+        # NaN  |    2
+        # NaN  |    3
+
+
+    Functional usage syntax:
+
+    .. code-block:: python
+
+        import pandas as pd
+        import janitor as jn
+
+        df = pd.DataFrame(...)
+        df = jn.process_text(
+            df = df,
+            string_function = "string_func_name_here",
+            args, kwargs
+            )
+
+    Method-chaining usage syntax:
+
+    .. code-block:: python
+
+        import pandas as pd
+        import janitor as jn
+
+        df = (
+            pd.DataFrame(...)
+            .process_text(
+                string_function = "string_func_name_here",
+                args, kwargs
+                )
+        )
+
+
+    :param df: A pandas dataframe.
+    :param column: String column to be operated on.
+    :param args, kwargs: Arguments for parameters.
+    :returns: A pandas dataframe with modified column.
+    :raises: KeyError if ``string_function`` is not a Pandas string method.
+    :raises: TypeError if wrong ``arg`` or ``kwarg`` is supplied.
+    """
+
+    pandas_string_methods = [
+        func.__name__
+        for _, func in inspect.getmembers(pd.Series.str, inspect.isfunction)
+        if not func.__name__.startswith("_")
+    ]
+
+    if string_function not in pandas_string_methods:
+        raise KeyError(f"{string_function} is not a Pandas string method.")
+
+    df[column] = getattr(df[column].str, string_function)(*args, **kwargs)
+
+    return df


Test patch:
diff --git a/tests/functions/test_process_text.py b/tests/functions/test_process_text.py
new file mode 100644
--- /dev/null
+++ b/tests/functions/test_process_text.py
@@ -0,0 +1,93 @@
+import numpy as np
+import pandas as pd
+import pytest
+from pandas.testing import assert_frame_equal
+
+
+def test_str_split():
+    "Test wrapper for Pandas ``.str.split()`` method."
+
+    df = pd.DataFrame(
+        {"text": ["a_b_c", "c_d_e", np.nan, "f_g_h"], "numbers": range(1, 5)}
+    )
+
+    expected = df.copy()
+    expected["text"] = expected["text"].str.split("_")
+
+    result = df.process_text(column="text", string_function="split", pat="_")
+
+    assert_frame_equal(result, expected)
+
+
+def test_str_cat():
+    "Test wrapper for Pandas ``.str.cat()`` method."
+
+    df = pd.DataFrame({"text": ["a", "b", "c", "d"], "numbers": range(1, 5)})
+
+    expected = df.copy()
+    expected["text"] = expected["text"].str.cat(others=["A", "B", "C", "D"])
+
+    result = df.process_text(
+        column="text", string_function="cat", others=["A", "B", "C", "D"],
+    )
+
+    assert_frame_equal(result, expected)
+
+
+def test_str_get():
+    """Test wrapper for Pandas ``.str.get()`` method."""
+
+    df = pd.DataFrame(
+        {"text": ["aA", "bB", "cC", "dD"], "numbers": range(1, 5)}
+    )
+
+    expected = df.copy()
+    expected["text"] = expected["text"].str.get(1)
+    result = df.process_text(column="text", string_function="get", i=-1)
+
+    assert_frame_equal(result, expected)
+
+
+def test_str_lower():
+    """Test string conversion to lowercase using ``.str.lower()``."""
+
+    df = pd.DataFrame(
+        {
+            "codes": range(1, 7),
+            "names": [
+                "Graham Chapman",
+                "John Cleese",
+                "Terry Gilliam",
+                "Eric Idle",
+                "Terry Jones",
+                "Michael Palin",
+            ],
+        }
+    )
+
+    expected = df.copy()
+    expected["names"] = expected["names"].str.lower()
+
+    result = df.process_text(column="names", string_function="lower")
+
+    assert_frame_equal(result, expected)
+
+
+def test_str_wrong():
+    """Test that an invalid Pandas string method raises an exception."""
+    df = pd.DataFrame(
+        {"text": ["ragnar", "sammywemmy", "ginger"], "code": [1, 2, 3]}
+    )
+    with pytest.raises(KeyError):
+        df.process_text(column="text", string_function="invalid_function")
+
+
+def test_str_wrong_parameters():
+    """Test that invalid argument for Pandas string method raises an error."""
+
+    df = pd.DataFrame(
+        {"text": ["a_b_c", "c_d_e", np.nan, "f_g_h"], "numbers": range(1, 5)}
+    )
+
+    with pytest.raises(TypeError):
+        df.process_text(column="text", string_function="split", pattern="_")