Repository: ericmjl/pyjanitor (python)

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).

Pull request:
[ENH] Pandas String Methods in a Single Class
# PR Description

Please describe the changes proposed in the pull request:

- Single function to access pandas' string methods
- Small subset to see if the current method is acceptable
-

<!-- Doing so provides maintainers with context on what the PR is, and can help us more effectively review your PR. -->

<!-- Please also identify below which issue that has been raised that you are going to close. -->

**This PR resolves #360 .**

<!-- 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.rst`.
<!-- We'd like to acknowledge your contributions! -->
3. [ ] Add a line to `CHANGELOG.rst` 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.

## Quick Check

To do a very quick check that everything is correct, follow these steps below:

- [x] Run the command `make check` from pyjanitor's top-level directory. This will automatically run:
    - black formatting
    - flake8 checking
    - running the test suite
    - docs build

Once done, please check off the check-box above.

If `make check` does not work for you, you can execute the commands listed in the Makefile individually.

## Code Changes

<!-- If you have not made code changes, please feel free to delete this section. -->

If you are adding code changes, please ensure the following:

- [x] Ensure that you have added tests.
- [ ] Run all tests (`$ pytest .`) locally on your machine.
    - [ ] Check to ensure that test coverage covers the lines of code that you have added.
    - [ ] Ensure that all tests pass.

## Documentation Changes

<!-- If you have not made documentation changes, please feel free to delete this section. -->

If you are adding documentation changes, please ensure the following:

- [ ] Build the docs locally.
- [ ] View the docs to check that it renders correctly.

# Relevant Reviewers

<!-- Finally, please tag relevant maintainers to review. -->

Please tag maintainers to review.

- @ericmjl

Patch:
diff --git a/.travis.yml b/.travis.yml
index 25ce189..a207fd8 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -18,21 +18,23 @@ install:
   - conda update -q conda
   - conda config --add channels conda-forge
 
+  - conda install -c conda-forge mamba
+
   # Useful for debugging any issues with conda
   - conda info -a
 
   # Install Python, py.test, and required packages.
-  - conda env create -f environment-dev.yml
+  - mamba env create -f environment-dev.yml
 
   # This guarantees that Python version is matrixed.
-  - conda install python=$PYTHON_VERSION
+  - mamba install python=$PYTHON_VERSION -n pyjanitor-dev
   - source $HOME/miniconda/etc/profile.d/conda.sh
   - conda activate pyjanitor-dev
   - python -m ipykernel install --name pyjanitor-dev --user
   - python setup.py develop
 
   # Build development container
-  - docker build -t ericmjl/pyjanitor:devcontainer -f .devcontainer/Dockerfile .
+  # - docker build -t ericmjl/pyjanitor:devcontainer -f .devcontainer/Dockerfile .
 
 # We use TravisCI to build docs and not run tests.
 # Tests are covered on Azure.
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index ed7ba03..c199f71 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -11,6 +11,7 @@ new version (on deck)
 - [DOC] Add the Optional type for all signatures of the API. @VPerrollaz
 - [TST] Updated test_expand_grid to account for int dtype difference in Windows OS @samukweku
 - [TST] Make importing ``pandas`` testing functions follow uniform pattern. @hectormz
+- [ENH] Added ``process_text`` wrapper function for all Pandas string methods. @samukweku
 
 
 v0.20.7
diff --git a/docs/reference/general_functions.rst b/docs/reference/general_functions.rst
index 3f8bbf3..736ee0c 100644
--- a/docs/reference/general_functions.rst
+++ b/docs/reference/general_functions.rst
@@ -86,3 +86,5 @@ Other
     move
     toset
     unionize_dataframe_categories
+    process_text
+    
diff --git a/janitor/functions.py b/janitor/functions.py
index 9a5c9d3..d93d60b 100644
--- 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
index 0000000..756dd53
--- /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="_")