pandas-dev/pandas · #70773

BUG: str regex \w, \d, \s and \b only matching ASCII for pyarrow-backed strings

maharanay22 · merged Oct 21, 20265 files · 196 + / 50 −
doc/source/whatsnew/v3.1.0.rst2 + / 0 −
@@ -997,6 +997,8 @@ Strings - Bug in :meth:`Series.str.normalize`, :meth:`Series.str.zfill` and their :class:`Index` counterparts with PyArrow-backed string dtypes raising ``ArrowInvalid`` or returning a ``null`` dtype when the object was empty, the values were all missing, or the missing values were grouped into a chunk of their own (:issue:`66822`) - Bug in addition and multiplication on length-zero ``string``-dtype :class:`Series` and :class:`Index` returning ``object`` dtype instead of the string dtype, and raising ``TypeError`` when the other operand was length-zero with a different string storage or ``object`` dtype (:issue:`40624`) - Bug in :meth:`Series.str.match` and :meth:`Index.str.match` raising for regex flags other than ``re.IGNORECASE`` (e.g. ``re.MULTILINE``, ``re.DOTALL``, ``re.ASCII``), whether passed via ``flags`` or carried by a compiled ``re.Pattern`` (:issue:`63108`, :issue:`66348`)+- Bug in :meth:`Series.str.contains`, :meth:`Series.str.match`, :meth:`Series.str.fullmatch`, :meth:`Series.str.replace` and :meth:`Series.str.count` for PyArrow-backed :class:`StringDtype` where ``\w``, ``\d``, ``\s`` and ``\b`` (and their negations) in a regular expression only matched ASCII characters (:issue:`70770`)+- Bug in :meth:`Series.str.contains`, :meth:`Series.str.match`, :meth:`Series.str.fullmatch`, :meth:`Series.str.replace` and :meth:`Series.str.count` for PyArrow-backed :class:`StringDtype` where a lookaround or backreference inside a repeat such as ``(?:a(?=b))+`` raised an error (:issue:`70770`) - Bug in :meth:`Series.str.match` and :meth:`Index.str.match` where passing ``flags=0`` was not equivalent to omitting ``flags``, raising for patterns using PyArrow-only regex syntax such as ``\p{L}`` or an inline flag such as ``(?i)`` (:issue:`63108`) - Bug in :meth:`Series.str.match`, :meth:`Series.str.fullmatch`, :meth:`Series.str.contains`, :meth:`Series.str.count`, :meth:`Series.str.extract`, :meth:`Series.str.replace` and their :class:`Index` counterparts with :class:`ArrowDtype` raising instead of honoring ``flags`` such as ``re.ASCII``, or the flags carried by a compiled ``re.Pattern`` (:issue:`66348`) - Bug in :meth:`Series.str.partition` and :meth:`Series.str.split` with ``expand=True`` raising for :class:`ArrowDtype` when the :class:`Series` was empty or held only null values; an empty :class:`Series` now expands to no columns and an all-null one to a single all-NA column, matching object dtype (:issue:`63602`)
pandas/core/arrays/_arrow_string_mixins.py74 + / 39 −
@@ -25,6 +25,7 @@  if TYPE_CHECKING:     from collections.abc import Callable+    from types import ModuleType      from pandas._typing import Scalar @@ -50,12 +51,24 @@ def _apply_elementwise(self, func: Callable[..., Any]) -> list[list[Any]]:         raise NotImplementedError      @staticmethod-    def _has_unsupported_regex(pat: str | re.Pattern[str]) -> bool:+    def _re_parser() -> ModuleType:+        try:+            # error: Module "re" has no attribute "_parser"+            from re import _parser  # type: ignore[attr-defined]+        except Exception as err:+            raise type(err)(+                "Incompatible version for regex; you will need to upgrade pandas "+                "or downgrade Python"+            ) from err+        return _parser++    @staticmethod+    def _regex_nodes(pat: str | re.Pattern[str]) -> list[tuple[Any, Any]]:         """-        Determine if regex pattern contains features not supported by RE2 / pyarrow.+        Parse a regex pattern into a flat list of its (op, arg) nodes. -        This includes lookaround (lookahead or lookbehind) assertions and-        backreferences.+        Nodes nested in groups, branches, repeats and character classes are+        included.          Parameters         ----------@@ -64,48 +77,72 @@ def _has_unsupported_regex(pat: str | re.Pattern[str]) -> bool:          Returns         --------        bool-            Whether `pat` contains a lookahead or lookbehind.+        list[tuple[Any, Any]]+            The nodes of `pat`, or an empty list if Python's ``re`` cannot+            parse it.         """-        try:-            # error: Module "re" has no attribute "_parser"-            from re import _parser  # type: ignore[attr-defined]+        _parser = ArrowStringArrayMixin._re_parser()+        repeats = {_parser.MAX_REPEAT, _parser.MIN_REPEAT, _parser.POSSESSIVE_REPEAT} -            regex_parser = _parser.parse-        except Exception as err:-            raise type(err)(-                "Incompatible version for regex; you will need to upgrade pandas "-                "or downgrade Python"-            ) from err--        def has_unsupported_code(tokens):-            # For certain op codes we need to recurse.+        def walk(tokens):             for op_code, argument in tokens:-                if (-                    (-                        op_code == _parser.SUBPATTERN-                        and has_unsupported_code(argument[3])-                    )-                    or (-                        op_code == _parser.BRANCH-                        and any(has_unsupported_code(tokens) for tokens in argument[1])-                    )-                    or (-                        op_code-                        in [_parser.ASSERT_NOT, _parser.ASSERT, _parser.GROUPREF]-                    )-                ):-                    return True-            return False+                yield op_code, argument+                if op_code == _parser.SUBPATTERN:+                    yield from walk(argument[3])+                elif op_code == _parser.BRANCH:+                    for branch in argument[1]:+                        yield from walk(branch)+                elif op_code in repeats:+                    yield from walk(argument[2])+                elif op_code in (_parser.ATOMIC_GROUP, _parser.IN):+                    yield from walk(argument)          str_pat = pat.pattern if isinstance(pat, re.Pattern) else pat         try:-            tokens = regex_parser(str_pat)+            tokens = _parser.parse(str_pat)         except re.error:             # Pattern not valid for Python's re (e.g. RE2 syntax like \x{...} or \p)             # Let the pyarrow backend handle it.+            return []+        return list(walk(tokens))++    def _needs_python_regex(self, pat: str | re.Pattern[str]) -> bool:+        """+        Whether a regex operation must use Python's ``re`` instead of pyarrow.++        This is the case for features not supported by RE2 / pyarrow (lookaround+        assertions and backreferences), and for ``\\w``, ``\\d``, ``\\s`` and+        ``\\b`` (or their negations) on non-ASCII data, since RE2 only matches+        them against ASCII characters while Python's ``re`` is Unicode-aware.+        """+        _parser = self._re_parser()+        unsupported = {_parser.ASSERT, _parser.ASSERT_NOT, _parser.GROUPREF}+        unicode_sensitive = {+            (_parser.CATEGORY, _parser.CATEGORY_WORD),+            (_parser.CATEGORY, _parser.CATEGORY_NOT_WORD),+            (_parser.CATEGORY, _parser.CATEGORY_DIGIT),+            (_parser.CATEGORY, _parser.CATEGORY_NOT_DIGIT),+            (_parser.CATEGORY, _parser.CATEGORY_SPACE),+            (_parser.CATEGORY, _parser.CATEGORY_NOT_SPACE),+            (_parser.AT, _parser.AT_BOUNDARY),+            (_parser.AT, _parser.AT_NON_BOUNDARY),+        }++        nodes = self._regex_nodes(pat)+        if any(op_code in unsupported for op_code, _ in nodes):+            return True+        if not any(+            (op_code, argument) in unicode_sensitive+            for op_code, argument in nodes+            if op_code in (_parser.CATEGORY, _parser.AT)+        ):             return False-        return has_unsupported_code(tokens)+        # all-ASCII data keeps the faster pyarrow path+        return self._has_non_ascii()++    def _has_non_ascii(self) -> bool:+        # min_count=0 so that an empty or all-null array reports False+        return not pc.all(pc.string_is_ascii(self._pa_array), min_count=0).as_py()      @staticmethod     def _is_re_pattern_with_flags(pat: str | re.Pattern[str]) -> bool:@@ -494,9 +531,7 @@ def _str_fullmatch(         return ArrowStringArrayMixin._str_match(self, pat, case, flags, na)      def _str_find(self, sub: str, start: int = 0, end: int | None = None):-        # min_count=0 so that an empty or all-null array reports True instead of-        #  null, keeping it on the pyarrow path below-        if not pc.all(pc.string_is_ascii(self._pa_array), min_count=0).as_py():+        if self._has_non_ascii():             # GH#64123 - pc.find_substring returns byte offsets instead of             # character offsets for multi-byte UTF-8 characters, so we fall back             # to Python str.find which correctly returns character offsets.
pandas/core/arrays/string_arrow.py5 + / 5 −
@@ -461,7 +461,7 @@ def _str_contains(         if (             flags             or self._is_re_pattern_with_flags(pat)-            or (regex and self._has_unsupported_regex(pat))+            or (regex and self._needs_python_regex(pat))             # a compiled pattern is not a valid literal; defer to the object path             or (not regex and isinstance(pat, re.Pattern))         ):@@ -482,7 +482,7 @@ def _str_match(         if (             flags             or self._is_re_pattern_with_flags(pat)-            or self._has_unsupported_regex(pat)+            or self._needs_python_regex(pat)         ):             return super()._str_match(pat, case, flags, na) @@ -499,7 +499,7 @@ def _str_fullmatch(         if (             flags             or self._is_re_pattern_with_flags(pat)-            or self._has_unsupported_regex(pat)+            or self._needs_python_regex(pat)         ):             return super()._str_fullmatch(pat, case, flags, na) @@ -524,7 +524,7 @@ def _str_replace(                 # https://docs.python.org/3/library/re.html                 isinstance(repl, str) and r"\g<" in repl             )-            or (regex and self._has_unsupported_regex(pat))+            or (regex and self._needs_python_regex(pat))         ):             return super()._str_replace(pat, repl, n, case, flags, regex) @@ -545,7 +545,7 @@ def _str_count(self, pat: str | re.Pattern[str], flags: int = 0):         if (             flags             or self._is_re_pattern_with_flags(pat)-            or self._has_unsupported_regex(pat)+            or self._needs_python_regex(pat)         ):             return super()._str_count(pat, flags) 
pandas/tests/strings/test_find_replace.py53 + / 0 −
@@ -1018,6 +1018,7 @@ def test_replace_regex_single_character(regex, any_string_dtype):         (r"(?<=a)b", ["aa", "ax", "ba", "bb"]),         (r"a(?!b)", ["xx", "ab", "bx", "bb"]),         (r"(?<!b)a", ["xx", "xb", "ba", "bb"]),+        (r"(?:a(?=b))+", ["aa", "xb", "ba", "bb"]),         ("ab", ["aa", "x", "ba", "bb"]),     ], )@@ -1037,6 +1038,33 @@ def test_replace_lookarounds(any_string_dtype, pat, expected_data):     tm.assert_series_equal(result, expected)  +@pytest.mark.parametrize(+    "pat, repl, expected_data",+    [+        (+            r"[^\w\s]",+            "",+            ["café crème", "MüllerLüdenscheidt", "東京 2024", "a\xa0 b"],+        ),+        (r"\s+", " ", ["café crème!", "Müller-Lüdenscheidt", "東京 2024", "a b"]),+        (+            r"\b東",+            "T",+            ["café crème!", "Müller-Lüdenscheidt", "T京 2024", "a\xa0 b"],+        ),+    ],+)+def test_replace_unicode_regex_classes(any_string_dtype, pat, repl, expected_data):+    # GH#70770+    ser = pd.Series(+        ["café crème!", "Müller-Lüdenscheidt", "東京 2024", "a\xa0 b"],+        dtype=any_string_dtype,+    )+    result = ser.str.replace(pat, repl, regex=True)+    expected = pd.Series(expected_data, dtype=any_string_dtype)+    tm.assert_series_equal(result, expected)++ def test_replace_end_of_string(any_string_dtype):     # https://github.com/pandas-dev/pandas/pull/63613     ser = pd.Series(["baz", "bar", "bars", "bar\n"], dtype=any_string_dtype)@@ -1627,6 +1655,31 @@ def test_fullmatch_lookarounds(any_string_dtype, pat):     tm.assert_series_equal(result, expected)  +@pytest.mark.parametrize(+    "method, pat, expected_data",+    [+        ("contains", r"\W", [False, True, True, False]),+        ("contains", r"\d", [False, False, True, True]),+        ("match", r"\w+\s", [False, False, True, False]),+        ("fullmatch", r"\w+", [True, False, False, True]),+        ("fullmatch", r"\w+(?:\s\w+)*", [True, False, True, True]),+    ],+)+def test_match_methods_unicode_regex_classes(+    any_string_dtype, method, pat, expected_data+):+    # GH#70770+    expected_dtype = (+        np.bool_ if is_object_or_nan_string_dtype(any_string_dtype) else "boolean"+    )+    ser = pd.Series(+        ["naïve", "Müller-Lüdenscheidt", "東京 2024", "١٢٣"], dtype=any_string_dtype+    )+    result = getattr(ser.str, method)(pat)+    expected = pd.Series(expected_data, dtype=expected_dtype)+    tm.assert_series_equal(result, expected)++ def test_fullmatch_end_of_string(any_string_dtype):     # https://github.com/pandas-dev/pandas/pull/63613     expected_dtype = (
pandas/tests/strings/test_strings.py62 + / 6 −
@@ -11,7 +11,6 @@  import pandas as pd import pandas._testing as tm-from pandas.core.arrays._arrow_string_mixins import ArrowStringArrayMixin from pandas.core.strings.accessor import StringMethods from pandas.tests.strings import is_object_or_nan_string_dtype @@ -131,6 +130,28 @@ def test_count_compiled_pattern_with_flags(any_string_dtype):     tm.assert_series_equal(result, expected)  +@pytest.mark.parametrize(+    "pat, expected_data",+    [+        (r"\w", [4, 6, 2, 3, 2]),+        (r"\s", [0, 1, 0, 0, 1]),+        (r"\d", [0, 0, 0, 3, 0]),+        (r"\b", [2, 4, 2, 2, 4]),+    ],+)+def test_count_unicode_regex_classes(any_string_dtype, pat, expected_data):+    # GH#70770+    expected_dtype = (+        "int64" if is_object_or_nan_string_dtype(any_string_dtype) else "Int64"+    )+    ser = pd.Series(+        ["café", "été nœl", "東京", "١٢٣", "a\xa0b"], dtype=any_string_dtype+    )+    result = ser.str.count(pat)+    expected = pd.Series(expected_data, dtype=expected_dtype)+    tm.assert_series_equal(result, expected)++ def test_repeat(any_string_dtype):     ser = pd.Series(["a", "b", np.nan, "c", np.nan, "d"], dtype=any_string_dtype) @@ -992,9 +1013,10 @@ def test_setitem_with_different_string_storage():         (r"[(?=)]", False),         (r"(?#(?=comment)", False),         (r"(test # (?=comment))", True),-        (r"(?=test)+", False),-        (r"(?=test)*", False),-        (r"(?=test)?", False),+        (r"(?=test)+", True),+        (r"(?=test)*", True),+        (r"(?=test)?", True),+        (r"(?:a(?=b))+", True),         (r"abc|(?=test)", True),         (r"^(?=test)$", True),         # backreferences@@ -1003,6 +1025,40 @@ def test_setitem_with_different_string_storage():         (r"\b(?P<word>\w+)\s+(?P=word)\b", True),     ], )-def test_has_regex_unsupported_code(pat, expected):+def test_needs_python_regex_unsupported_code(pat, expected):     # https://github.com/pandas-dev/pandas/issues/60833-    assert ArrowStringArrayMixin._has_unsupported_regex(pat) == expected+    pytest.importorskip("pyarrow")+    arr = pd.array(["abc"], dtype=pd.StringDtype("pyarrow"))+    assert arr._needs_python_regex(pat) == expected+++@pytest.mark.parametrize(+    "pat, expected",+    [+        (r"\w+", True),+        (r"\W", True),+        (r"\d{3}", True),+        (r"\D", True),+        (r"\s+", True),+        (r"\S", True),+        (r"\bword\b", True),+        (r"\B", True),+        (r"[^\w\s]", True),+        (r"[\w-]", True),+        (r"(?:x\S)*", True),+        (r"(foo|bar\d)", True),+        (r"(?>\w)", True),+        (r"[a-z0-9_]+", False),+        (r"\\w", False),+        (r"abc", False),+        (r"", False),+    ],+)+def test_needs_python_regex_unicode_classes(pat, expected):+    # GH#70770+    pytest.importorskip("pyarrow")+    arr = pd.array(["é"], dtype=pd.StringDtype("pyarrow"))+    assert arr._needs_python_regex(pat) == expected+    # all-ASCII data keeps the pyarrow path+    ascii_arr = pd.array(["e", None], dtype=pd.StringDtype("pyarrow"))+    assert not ascii_arr._needs_python_regex(pat)