Repository: pyjanitor-devs/pyjanitor

Issue:
new function proposal: find and replace
Colleague proposed this function: within a column that houses strings, within each cell, provide a `find_replace()` function that finds a substring in the cell, and replaces it with another substring.

Note to self:

- Figure out if this is already possible with pandas.
- Decide if implementing this provides a more fluent function API.


Discussion:
Should it support regular expressions? 
🤔 
Regex is ok.... but globbing is nicer:

https://docs.python.org/3.6/library/fnmatch.html
@zbarry I see you're saturated with relatives!

I think it's possible to support multiple ways of find and replace:

```python
def find_replace(df, find, replace, kind='exact'):
    kinds=['exact', 'regex', 'glob']
    if kind not in kinds:
        raise ValueError(f'kind kwarg has to be one of {kinds}')
    if kind == 'exact':
        # implementation goes here
    if kind == 'regex':
        # implementation goes here
    if kind == 'glob':
        # implementation goes here
    return df
```

What do you all think?
The above looks good, except “exact” is unnecessary.  Regex or glob without wildcards will be equivalent. 
Ok, got it! Pardon my ignorance here, but is globbing a "subset" of regexing, or there mutually exclusive parts to them?
So they’re kind-of different things. I think this answer gives a better description of the differences than I could write here: 

https://askubuntu.com/questions/714503/regular-expressions-vs-filename-globbing

In general, when searching for things I find globbing more useful (cause you just need an asterisk) but for validation regex is better because one can be more precise with what is expected. 
Yeah 90% of the time I just use globbing. I only use regex when I absolutely have to. No idea how often you would need them in pandas-style applications.
Ok! Synthesizing the discussion thus far:

```python
def find_replace(df, col_name, find, replace, kind='glob'):
    """
    # docstring opener above
    Whether or not you use kind="glob" or "regex", if you do not use any wildcards,
    it will default to an exact match. We expect an 80/20 split between exact/wildcard
    usage, and with globbing being more easily understood than regexes, it is hence
    the default choice.
    # continue docstring below
    """
    kinds=['regex', 'glob']
    if kind not in kinds:
        raise ValueError(f'kind kwarg has to be one of {kinds}')
    if kind == 'regex':
        # implementation goes here
    else:
        # implementation goes here
    return df
```

Comments?
Needs a `column_name` / `col_name` (?), right?
> Needs a column_name / col_name (?), right?

Thank you. Updating...
Also think that maybe `col_name` should come before `find` and `replace` in the args list... From the at-a-glance understanding perspective, maybe `df.do_this(to_this, with_this_particularly)` is more in line with what we have done with other functions.
@zbarry good point! Updated the function signature.

Resolving patch:
diff --git a/janitor/functions.py b/janitor/functions.py
--- a/janitor/functions.py
+++ b/janitor/functions.py
@@ -4,9 +4,9 @@
 import datetime as dt
 import re
 import warnings
+from fnmatch import translate
 from functools import partial, reduce
 from typing import Dict, Iterable, List, Union
-from fnmatch import translate
 
 import numpy as np
 import pandas as pd
@@ -2449,3 +2449,33 @@ def dropnotnull(df, column: str):
     :param df: A pandas DataFrame.
     """
     return df[pd.isnull(df[column])]
+
+
+@pf.register_dataframe_method
+def find_replace(df: pd.DataFrame, column: str, mapper: dict):
+    """
+    Performs a find-and-replace action on a column of data.
+
+    For example, let's say we have a column for which we want to replace all
+    of the values 'a' with 1, 'b' with 2, 'c' with 3. We would use the
+    following function call:
+
+    .. code-block:: python
+
+        df = (
+            pd.DataFrame(...)
+            .find_replace('column_name', {'a': 1, 'b': 2, 'c': 3})
+        )
+
+    This find-and-replace functionality does an exact match only. Hence,
+    substring matches do not work. The value of a cell in the dataframe
+    must be exactly 'a', 'b', or 'c', otherwise the replacement will not
+    happen and the original data will be left in-place.
+
+    :param df: A pandas DataFrame.
+    :param column: The column on which the find/replace action is to be made.
+    :param mapper: A dictionary that maps "thing to find" -> "thing to
+        replace".
+    """
+    df[column] = df[column].apply(lambda x: mapper.get(x, x))
+    return df


Test patch:
diff --git a/tests/functions/test_find_replace.py b/tests/functions/test_find_replace.py
new file mode 100644
--- /dev/null
+++ b/tests/functions/test_find_replace.py
@@ -0,0 +1,13 @@
+import numpy as np
+import pandas as pd
+import pytest
+
+
+@pytest.mark.test
+@pytest.mark.functions
+def test_find_replace():
+    df = pd.DataFrame(
+        {"a": [1, np.nan, 3], "b": [2, 3, 1], "c": [2, np.nan, 9]}
+    ).find_replace("a", {1: 2, 3: 4, np.nan: 5})
+
+    assert not all(pd.isnull(df["a"]))