Repository: modin-project/modin

Issue:
REFACTOR: add query compiler interfaces for some string methods
- https://github.com/ponder-org/modin-public/commit/cca67410fbde35117dbefb4cfd5d02ccb9b64124
- https://github.com/ponder-org/modin-public/commit/c7278871e9326e68e015a01f596913023cb22de8
- https://github.com/ponder-org/modin-public/commit/c271c921e96a7dae43018e0adb5b2be93bcb0e04


Discussion:
It turns out that someone has already added `day_of_year` and `day_of_week`.
methods in series_utils here have already been added: https://github.com/ponder-org/modin-public/commit/c271c921e96a7dae43018e0adb5b2be93bcb0e04#diff-890c48e6da7113aa9bdb7e7f9e219d21ea51fa1b550fb02327fca8738b2da54a

Resolving patch:
diff --git a/modin/core/storage_formats/base/query_compiler.py b/modin/core/storage_formats/base/query_compiler.py
--- a/modin/core/storage_formats/base/query_compiler.py
+++ b/modin/core/storage_formats/base/query_compiler.py
@@ -4839,6 +4839,37 @@ def str_zfill(self, width):
     def str___getitem__(self, key):
         return StrDefault.register(pandas.Series.str.__getitem__)(self, key)
 
+    @doc_utils.doc_str_method(
+        refer_to="encode",
+        params="""
+            encoding : str,
+            errors : str, default = 'strict'""",
+    )
+    def str_encode(self, encoding, errors):
+        return StrDefault.register(pandas.Series.str.encode)(self, encoding, errors)
+
+    @doc_utils.doc_str_method(
+        refer_to="decode",
+        params="""
+                encoding : str,
+                errors : str, default = 'strict'""",
+    )
+    def str_decode(self, encoding, errors):
+        return StrDefault.register(pandas.Series.str.decode)(self, encoding, errors)
+
+    @doc_utils.doc_str_method(
+        refer_to="cat",
+        params="""
+            others : Series, Index, DataFrame, np.ndarray or list-like,
+            sep : str, default: '',
+            na_rep : str or None, default: None,
+            join : {'left', 'right', 'outer', 'inner'}, default: 'left'""",
+    )
+    def str_cat(self, others, sep=None, na_rep=None, join="left"):
+        return StrDefault.register(pandas.Series.str.cat)(
+            self, others, sep, na_rep, join
+        )
+
     # End of Str methods
 
     # Rolling methods
diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py
--- a/modin/core/storage_formats/pandas/query_compiler.py
+++ b/modin/core/storage_formats/pandas/query_compiler.py
@@ -1643,7 +1643,14 @@ def isin_func(df, values):
     str_match = Map.register(_str_map("match"), dtypes="copy")
     str_normalize = Map.register(_str_map("normalize"), dtypes="copy")
     str_pad = Map.register(_str_map("pad"), dtypes="copy")
-    str_partition = Map.register(_str_map("partition"), dtypes="copy")
+    _str_partition = Map.register(_str_map("partition"), dtypes="copy")
+
+    def str_partition(self, sep=" ", expand=True):
+        # For `expand`, need an operator that can create more columns than before
+        if expand:
+            return super().str_partition(sep=sep, expand=expand)
+        return self._str_partition(sep=sep, expand=False)
+
     str_repeat = Map.register(_str_map("repeat"), dtypes="copy")
     _str_extract = Map.register(_str_map("extract"), dtypes="copy")
 
@@ -1652,25 +1659,46 @@ def str_extract(self, pat, flags, expand):
         # need an operator that can create more columns than before
         if expand and regex.groups == 1:
             qc = self._str_extract(pat, flags=flags, expand=expand)
+            qc.columns = get_group_names(regex)
         else:
             qc = super().str_extract(pat, flags=flags, expand=expand)
-        qc.columns = get_group_names(regex)
         return qc
 
     str_replace = Map.register(_str_map("replace"), dtypes="copy", shape_hint="column")
     str_rfind = Map.register(_str_map("rfind"), dtypes="copy", shape_hint="column")
     str_rindex = Map.register(_str_map("rindex"), dtypes="copy", shape_hint="column")
     str_rjust = Map.register(_str_map("rjust"), dtypes="copy", shape_hint="column")
-    str_rpartition = Map.register(
+    _str_rpartition = Map.register(
         _str_map("rpartition"), dtypes="copy", shape_hint="column"
     )
-    str_rsplit = Map.register(_str_map("rsplit"), dtypes="copy", shape_hint="column")
+
+    def str_rpartition(self, sep=" ", expand=True):
+        if expand:
+            # For `expand`, need an operator that can create more columns than before
+            return super().str_rpartition(sep=sep, expand=expand)
+        return self._str_rpartition(sep=sep, expand=False)
+
+    _str_rsplit = Map.register(_str_map("rsplit"), dtypes="copy", shape_hint="column")
+
+    def str_rsplit(self, pat=None, n=-1, expand=False):
+        if expand:
+            # For `expand`, need an operator that can create more columns than before
+            return super().str_rsplit(pat=pat, n=n, expand=expand)
+        return self._str_rsplit(pat=pat, n=n, expand=False)
+
     str_rstrip = Map.register(_str_map("rstrip"), dtypes="copy", shape_hint="column")
     str_slice = Map.register(_str_map("slice"), dtypes="copy", shape_hint="column")
     str_slice_replace = Map.register(
         _str_map("slice_replace"), dtypes="copy", shape_hint="column"
     )
-    str_split = Map.register(_str_map("split"), dtypes="copy", shape_hint="column")
+    _str_split = Map.register(_str_map("split"), dtypes="copy", shape_hint="column")
+
+    def str_split(self, pat=None, n=-1, expand=False, regex=None):
+        if expand:
+            # For `expand`, need an operator that can create more columns than before
+            return super().str_split(pat=pat, n=n, expand=expand, regex=regex)
+        return self._str_split(pat=pat, n=n, expand=False, regex=regex)
+
     str_startswith = Map.register(
         _str_map("startswith"), dtypes=np.bool_, shape_hint="column"
     )
diff --git a/modin/pandas/series_utils.py b/modin/pandas/series_utils.py
--- a/modin/pandas/series_utils.py
+++ b/modin/pandas/series_utils.py
@@ -17,19 +17,12 @@
 Accessors: `Series.cat`, `Series.str`, `Series.dt`
 """
 from typing import TYPE_CHECKING
-import sys
+import re
 import numpy as np
 import pandas
 from modin.logging import ClassLogger
 from modin.utils import _inherit_docstrings
 
-if sys.version_info[0] == 3 and sys.version_info[1] >= 7:
-    # Python >= 3.7
-    from re import Pattern as _pattern_type
-else:
-    # Python <= 3.6
-    from re import _pattern_type
-
 if TYPE_CHECKING:
     from datetime import tzinfo
     from pandas._typing import npt
@@ -154,22 +147,29 @@ def casefold(self):
     def cat(self, others=None, sep=None, na_rep=None, join="left"):
         if isinstance(others, self._Series):
             others = others._to_pandas()
-        return self._default_to_pandas(
-            pandas.Series.str.cat, others=others, sep=sep, na_rep=na_rep, join=join
+        compiler_result = self._query_compiler.str_cat(
+            others=others, sep=sep, na_rep=na_rep, join=join
+        )
+        # if others is None, result is a string. otherwise, it's a series.
+        return (
+            compiler_result.to_pandas().squeeze()
+            if others is None
+            else self._Series(query_compiler=compiler_result)
         )
 
     def decode(self, encoding, errors="strict"):
-        return self._default_to_pandas(
-            pandas.Series.str.decode, encoding, errors=errors
+        return self._Series(
+            query_compiler=self._query_compiler.str_decode(encoding, errors)
         )
 
     def split(self, pat=None, n=-1, expand=False, regex=None):
-        if not pat and pat is not None:
-            raise ValueError("split() requires a non-empty pattern match.")
-
         if expand:
-            return self._default_to_pandas(
-                pandas.Series.str.split, pat=pat, n=n, expand=expand, regex=regex
+            from .dataframe import DataFrame
+
+            return DataFrame(
+                query_compiler=self._query_compiler.str_split(
+                    pat=pat, n=n, expand=True, regex=regex
+                )
             )
         else:
             return self._Series(
@@ -183,8 +183,12 @@ def rsplit(self, pat=None, n=-1, expand=False):
             raise ValueError("rsplit() requires a non-empty pattern match.")
 
         if expand:
-            return self._default_to_pandas(
-                pandas.Series.str.rsplit, pat=pat, n=n, expand=expand
+            from .dataframe import DataFrame
+
+            return DataFrame(
+                query_compiler=self._query_compiler.str_rsplit(
+                    pat=pat, n=n, expand=True
+                )
             )
         else:
             return self._Series(
@@ -279,7 +283,7 @@ def slice_replace(self, start=None, stop=None, repl=None):
         )
 
     def count(self, pat, flags=0):
-        if not isinstance(pat, (str, _pattern_type)):
+        if not isinstance(pat, (str, re.Pattern)):
             raise TypeError("first argument must be string or compiled pattern")
         return self._Series(
             query_compiler=self._query_compiler.str_count(pat, flags=flags)
@@ -291,8 +295,8 @@ def startswith(self, pat, na=None):
         )
 
     def encode(self, encoding, errors="strict"):
-        return self._default_to_pandas(
-            pandas.Series.str.encode, encoding, errors=errors
+        return self._Series(
+            query_compiler=self._query_compiler.str_encode(encoding, errors)
         )
 
     def endswith(self, pat, na=None):
@@ -301,14 +305,14 @@ def endswith(self, pat, na=None):
         )
 
     def findall(self, pat, flags=0):
-        if not isinstance(pat, (str, _pattern_type)):
+        if not isinstance(pat, (str, re.Pattern)):
             raise TypeError("first argument must be string or compiled pattern")
         return self._Series(
             query_compiler=self._query_compiler.str_findall(pat, flags=flags)
         )
 
     def fullmatch(self, pat, case=True, flags=0, na=None):
-        if not isinstance(pat, (str, _pattern_type)):
+        if not isinstance(pat, (str, re.Pattern)):
             raise TypeError("first argument must be string or compiled pattern")
         return self._Series(
             query_compiler=self._query_compiler.str_fullmatch(
@@ -317,7 +321,7 @@ def fullmatch(self, pat, case=True, flags=0, na=None):
         )
 
     def match(self, pat, case=True, flags=0, na=None):
-        if not isinstance(pat, (str, _pattern_type)):
+        if not isinstance(pat, (str, re.Pattern)):
             raise TypeError("first argument must be string or compiled pattern")
         return self._Series(
             query_compiler=self._query_compiler.str_match(
@@ -326,16 +330,15 @@ def match(self, pat, case=True, flags=0, na=None):
         )
 
     def extract(self, pat, flags=0, expand=True):
-        if expand:
-            from .dataframe import DataFrame
+        query_compiler = self._query_compiler.str_extract(
+            pat, flags=flags, expand=expand
+        )
+        from .dataframe import DataFrame
 
-            return DataFrame(
-                query_compiler=self._query_compiler.str_extract(
-                    pat, flags=flags, expand=expand
-                )
-            )
-        return self._default_to_pandas(
-            pandas.Series.str.extract, pat, flags=flags, expand=expand
+        return (
+            DataFrame(query_compiler=query_compiler)
+            if expand or re.compile(pat).groups > 1
+            else self._Series(query_compiler=query_compiler)
         )
 
     def extractall(self, pat, flags=0):
@@ -363,16 +366,11 @@ def partition(self, sep=" ", expand=True):
         if sep is not None and len(sep) == 0:
             raise ValueError("empty separator")
 
-        if expand:
-            return self._default_to_pandas(
-                pandas.Series.str.partition, sep=sep, expand=expand
-            )
-        else:
-            return self._Series(
-                query_compiler=self._query_compiler.str_partition(
-                    sep=sep, expand=expand
-                )
-            )
+        from .dataframe import DataFrame
+
+        return (DataFrame if expand else self._Series)(
+            query_compiler=self._query_compiler.str_partition(sep=sep, expand=expand)
+        )
 
     def removeprefix(self, prefix):
         return self._Series(
@@ -391,16 +389,11 @@ def rpartition(self, sep=" ", expand=True):
         if sep is not None and len(sep) == 0:
             raise ValueError("empty separator")
 
-        if expand:
-            return self._default_to_pandas(
-                pandas.Series.str.rpartition, sep=sep, expand=expand
-            )
-        else:
-            return self._Series(
-                query_compiler=self._query_compiler.str_rpartition(
-                    sep=sep, expand=expand
-                )
-            )
+        from .dataframe import DataFrame
+
+        return (DataFrame if expand else self._Series)(
+            query_compiler=self._query_compiler.str_rpartition(sep=sep, expand=expand)
+        )
 
     def lower(self):
         return self._Series(query_compiler=self._query_compiler.str_lower())


Test patch:
diff --git a/modin/pandas/test/test_series.py b/modin/pandas/test/test_series.py
--- a/modin/pandas/test/test_series.py
+++ b/modin/pandas/test/test_series.py
@@ -11,6 +11,8 @@
 # ANY KIND, either express or implied. See the License for the specific language
 # governing permissions and limitations under the License.
 
+from __future__ import annotations
+
 import pytest
 import numpy as np
 import json
@@ -24,7 +26,6 @@
 
 from modin.utils import get_current_execution
 from modin.test.test_utils import warns_that_defaulting_to_pandas
-import sys
 
 from modin.utils import to_pandas
 from .utils import (
@@ -3851,20 +3852,14 @@ def test_str___getitem__(data, key):
 
 
 # Test str operations
-def test_str_cat():
+@pytest.mark.parametrize(
+    "others",
+    [["abC|DeF,Hik", "gSaf,qWer|Gre", "asd3,4sad|", np.NaN], None],
+    ids=["list", "None"],
+)
+def test_str_cat(others):
     data = ["abC|DeF,Hik", "gSaf,qWer|Gre", "asd3,4sad|", np.NaN]
-    modin_series, pandas_series = create_test_series(data)
-    others = data
-
-    with warns_that_defaulting_to_pandas():
-        # We are only testing that this defaults to pandas, so we will just check for
-        # the warning
-        modin_series.str.cat(others)
-
-    with warns_that_defaulting_to_pandas():
-        # We are only testing that this defaults to pandas, so we will just check for
-        # the warning
-        modin_series.str.cat(None)
+    eval_general(*create_test_series(data), lambda s: s.str.cat(others=others))
 
 
 @pytest.mark.parametrize("data", test_string_data_values, ids=test_string_data_keys)
@@ -3872,24 +3867,10 @@ def test_str_cat():
 @pytest.mark.parametrize("n", int_arg_values, ids=int_arg_keys)
 @pytest.mark.parametrize("expand", bool_arg_values, ids=bool_arg_keys)
 def test_str_split(data, pat, n, expand):
-    # Empty pattern not supported on Python 3.7+
-    if sys.version_info[0] == 3 and sys.version_info[1] >= 7 and pat == "":
-        return
-
-    modin_series, pandas_series = create_test_series(data)
-
-    if n >= -1:
-        if expand and pat:
-            with warns_that_defaulting_to_pandas():
-                # We are only testing that this defaults to pandas, so we will just check for
-                # the warning
-                modin_series.str.split(pat, n=n, expand=expand)
-        elif not expand:
-            eval_general(
-                modin_series,
-                pandas_series,
-                lambda series: series.str.split(pat, n=n, expand=expand),
-            )
+    eval_general(
+        *create_test_series(data),
+        lambda series: series.str.split(pat, n=n, expand=expand),
+    )
 
 
 @pytest.mark.parametrize("data", test_string_data_values, ids=test_string_data_keys)
@@ -3897,20 +3878,10 @@ def test_str_split(data, pat, n, expand):
 @pytest.mark.parametrize("n", int_arg_values, ids=int_arg_keys)
 @pytest.mark.parametrize("expand", bool_arg_values, ids=bool_arg_keys)
 def test_str_rsplit(data, pat, n, expand):
-    modin_series, pandas_series = create_test_series(data)
-
-    if n >= -1:
-        if expand and pat:
-            with warns_that_defaulting_to_pandas():
-                # We are only testing that this defaults to pandas, so we will just check for
-                # the warning
-                modin_series.str.rsplit(pat, n=n, expand=expand)
-        elif not expand:
-            eval_general(
-                modin_series,
-                pandas_series,
-                lambda series: series.str.rsplit(pat, n=n, expand=expand),
-            )
+    eval_general(
+        *create_test_series(data),
+        lambda series: series.str.rsplit(pat, n=n, expand=expand),
+    )
 
 
 @pytest.mark.parametrize("data", test_string_data_values, ids=test_string_data_keys)
@@ -4506,14 +4477,44 @@ def test_casefold(data):
     eval_general(modin_series, pandas_series, lambda series: series.str.casefold())
 
 
-@pytest.mark.parametrize("encoding_type", encoding_types)
-@pytest.mark.parametrize("data", test_string_data_values, ids=test_string_data_keys)
-def test_encode(data, encoding_type):
-    modin_series, pandas_series = create_test_series(data)
+@pytest.fixture
+def str_encode_decode_test_data() -> list[str]:
+    return [
+        "abC|DeF,Hik",
+        "234,3245.67",
+        "gSaf,qWer|Gre",
+        "asd3,4sad|",
+        np.NaN,
+        None,
+        # add a string that we can't encode in ascii, and whose utf-8 encoding
+        # we cannot decode in ascii
+        "ക",
+    ]
+
+
+@pytest.mark.parametrize("encoding", encoding_types)
+@pytest.mark.parametrize("errors", ["strict", "ignore", "replace"])
+def test_str_encode(encoding, errors, str_encode_decode_test_data):
     eval_general(
-        modin_series,
-        pandas_series,
-        lambda series: series.str.encode(encoding=encoding_type),
+        *create_test_series(str_encode_decode_test_data),
+        lambda s: s.str.encode(encoding, errors=errors),
+    )
+
+
+@pytest.mark.parametrize(
+    "encoding",
+    encoding_types,
+)
+@pytest.mark.parametrize("errors", ["strict", "ignore", "replace"])
+def test_str_decode(encoding, errors, str_encode_decode_test_data):
+    eval_general(
+        *create_test_series(
+            [
+                s.encode("utf-8") if isinstance(s, str) else s
+                for s in str_encode_decode_test_data
+            ]
+        ),
+        lambda s: s.str.decode(encoding, errors=errors),
     )