Repository: pandas-dev/pandas

Issue:
Should str.replace accept a compiled expression?
The current solution is to call `str.replace(<compiled_re>.pattern, flags=<compiled_re>.flags)` which is relatively ugly and verbose in my opnion.

Here's a contrived example of removing stopwords and normalizing whitespace afterwards:

```python
import pandas as pd
import re

some_names = pd.Series(["three weddings and a funeral", "the big lebowski", "florence and the machine"])

stopwords = ["the", "a", "and"]
stopwords_re = re.compile(r"(\s+)?\b({})\b(\s+)?".format("|".join(stopwords), re.IGNORECASE)
whitespace_re = re.compile(r"\s+")

# desired code:
# some_names.str.replace(stopwords_re, " ").str.strip().str.replace(whitespace_re, " ")

# actual code:
some_names.\
    str.replace(stopwords_re.pattern, " ", flags=stopwords_re.flags).\
    str.strip().str.replace(whitespace_re.pattern, " ", flags=whitespace_re.flags)
```

Why do I think this is better?

1. It's [nice](http://stackoverflow.com/a/453568/2954547) to have commonly used regular expressions compiled and to carry their flags around with them (and also allows the use of ["verbose" regular expressions](http://stackoverflow.com/a/29159014/2954547))
2. It's not that compiled regular expressions should quack like strings... it's that in this case we're making strings quack like compiled regular expressions, but at the same time not letting those compiled regular expressions quack their own quack.

Is there a good reason _not_ to implement this?


Discussion:
> Is there a good reason not to implement this?

I can't think of one. Feel free to give it a shot!
yup these reasonable (and straightforward) to do.

Resolving patch:
diff --git a/pandas/core/strings.py b/pandas/core/strings.py
--- a/pandas/core/strings.py
+++ b/pandas/core/strings.py
@@ -9,7 +9,8 @@
                                  is_string_like,
                                  is_list_like,
                                  is_scalar,
-                                 is_integer)
+                                 is_integer,
+                                 is_re)
 from pandas.core.common import _values_from_object
 
 from pandas.core.algorithms import take_1d
@@ -303,7 +304,7 @@ def str_endswith(arr, pat, na=np.nan):
     return _na_map(f, arr, na, dtype=bool)
 
 
-def str_replace(arr, pat, repl, n=-1, case=True, flags=0):
+def str_replace(arr, pat, repl, n=-1, case=None, flags=0):
     """
     Replace occurrences of pattern/regex in the Series/Index with
     some other string. Equivalent to :meth:`str.replace` or
@@ -311,8 +312,12 @@ def str_replace(arr, pat, repl, n=-1, case=True, flags=0):
 
     Parameters
     ----------
-    pat : string
-        Character sequence or regular expression
+    pat : string or compiled regex
+        String can be a character sequence or regular expression.
+
+        .. versionadded:: 0.20.0
+            `pat` also accepts a compiled regex.
+
     repl : string or callable
         Replacement string or a callable. The callable is passed the regex
         match object and must return a replacement string to be used.
@@ -323,15 +328,24 @@ def str_replace(arr, pat, repl, n=-1, case=True, flags=0):
 
     n : int, default -1 (all)
         Number of replacements to make from start
-    case : boolean, default True
-        If True, case sensitive
+    case : boolean, default None
+        - If True, case sensitive (the default if `pat` is a string)
+        - Set to False for case insensitive
+        - Cannot be set if `pat` is a compiled regex
     flags : int, default 0 (no flags)
-        re module flags, e.g. re.IGNORECASE
+        - re module flags, e.g. re.IGNORECASE
+        - Cannot be set if `pat` is a compiled regex
 
     Returns
     -------
     replaced : Series/Index of objects
 
+    Notes
+    -----
+    When `pat` is a compiled regex, all flags should be included in the
+    compiled regex. Use of `case` or `flags` with a compiled regex will
+    raise an error.
+
     Examples
     --------
     When `repl` is a string, every `pat` is replaced as with
@@ -372,21 +386,42 @@ def str_replace(arr, pat, repl, n=-1, case=True, flags=0):
     0    tWO
     1    bAR
     dtype: object
+
+    Using a compiled regex with flags
+
+    >>> regex_pat = re.compile(r'FUZ', flags=re.IGNORECASE)
+    >>> pd.Series(['foo', 'fuz', np.nan]).str.replace(regex_pat, 'bar')
+    0    foo
+    1    bar
+    2    NaN
+    dtype: object
     """
 
     # Check whether repl is valid (GH 13438, GH 15055)
     if not (is_string_like(repl) or callable(repl)):
         raise TypeError("repl must be a string or callable")
-    use_re = not case or len(pat) > 1 or flags or callable(repl)
 
-    if use_re:
-        if not case:
+    is_compiled_re = is_re(pat)
+    if is_compiled_re:
+        if (case is not None) or (flags != 0):
+            raise ValueError("case and flags cannot be set"
+                             " when pat is a compiled regex")
+    else:
+        # not a compiled regex
+        # set default case
+        if case is None:
+            case = True
+
+        # add case flag, if provided
+        if case is False:
             flags |= re.IGNORECASE
-        regex = re.compile(pat, flags=flags)
-        n = n if n >= 0 else 0
 
-        def f(x):
-            return regex.sub(repl, x, count=n)
+    use_re = is_compiled_re or len(pat) > 1 or flags or callable(repl)
+
+    if use_re:
+        n = n if n >= 0 else 0
+        regex = re.compile(pat, flags=flags)
+        f = lambda x: regex.sub(repl=repl, string=x, count=n)
     else:
         f = lambda x: x.replace(pat, repl, n)
 
@@ -1558,7 +1593,7 @@ def match(self, pat, case=True, flags=0, na=np.nan, as_indexer=False):
         return self._wrap_result(result)
 
     @copy(str_replace)
-    def replace(self, pat, repl, n=-1, case=True, flags=0):
+    def replace(self, pat, repl, n=-1, case=None, flags=0):
         result = str_replace(self._data, pat, repl, n=n, case=case,
                              flags=flags)
         return self._wrap_result(result)


Test patch:
diff --git a/pandas/tests/test_strings.py b/pandas/tests/test_strings.py
--- a/pandas/tests/test_strings.py
+++ b/pandas/tests/test_strings.py
@@ -469,6 +469,65 @@ def test_replace_callable(self):
         exp = Series(['bAR', NA])
         tm.assert_series_equal(result, exp)
 
+    def test_replace_compiled_regex(self):
+        # GH 15446
+        values = Series(['fooBAD__barBAD', NA])
+
+        # test with compiled regex
+        pat = re.compile(r'BAD[_]*')
+        result = values.str.replace(pat, '')
+        exp = Series(['foobar', NA])
+        tm.assert_series_equal(result, exp)
+
+        # mixed
+        mixed = Series(['aBAD', NA, 'bBAD', True, datetime.today(), 'fooBAD',
+                        None, 1, 2.])
+
+        rs = Series(mixed).str.replace(pat, '')
+        xp = Series(['a', NA, 'b', NA, NA, 'foo', NA, NA, NA])
+        tm.assertIsInstance(rs, Series)
+        tm.assert_almost_equal(rs, xp)
+
+        # unicode
+        values = Series([u('fooBAD__barBAD'), NA])
+
+        result = values.str.replace(pat, '')
+        exp = Series([u('foobar'), NA])
+        tm.assert_series_equal(result, exp)
+
+        result = values.str.replace(pat, '', n=1)
+        exp = Series([u('foobarBAD'), NA])
+        tm.assert_series_equal(result, exp)
+
+        # flags + unicode
+        values = Series([b"abcd,\xc3\xa0".decode("utf-8")])
+        exp = Series([b"abcd, \xc3\xa0".decode("utf-8")])
+        pat = re.compile(r"(?<=\w),(?=\w)", flags=re.UNICODE)
+        result = values.str.replace(pat, ", ")
+        tm.assert_series_equal(result, exp)
+
+        # case and flags provided to str.replace will have no effect
+        # and will produce warnings
+        values = Series(['fooBAD__barBAD__bad', NA])
+        pat = re.compile(r'BAD[_]*')
+
+        with tm.assertRaisesRegexp(ValueError, "case and flags must be"):
+            result = values.str.replace(pat, '', flags=re.IGNORECASE)
+
+        with tm.assertRaisesRegexp(ValueError, "case and flags must be"):
+            result = values.str.replace(pat, '', case=False)
+
+        with tm.assertRaisesRegexp(ValueError, "case and flags must be"):
+            result = values.str.replace(pat, '', case=True)
+
+        # test with callable
+        values = Series(['fooBAD__barBAD', NA])
+        repl = lambda m: m.group(0).swapcase()
+        pat = re.compile('[a-z][A-Z]{2}')
+        result = values.str.replace(pat, repl, n=2)
+        exp = Series(['foObaD__baRbaD', NA])
+        tm.assert_series_equal(result, exp)
+
     def test_repeat(self):
         values = Series(['a', 'b', NA, 'c', NA, 'd'])