From cd25d740d528d71f1c57ec29adf350a03e047c76 Mon Sep 17 00:00:00 2001 From: Novain Shibu Date: Wed, 14 Oct 2026 07:10:59 +0530 Subject: [PATCH 1/3] Fix case-insensitive literal matching in str.replace and str.contains str.replace ignored case=False when regex=False and returned the input unchanged. str.contains with case=False, regex=False raised AttributeError on object-dtype and StringDType arrays. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Novain Shibu --- doc/whats-new.rst | 5 ++++ xarray/core/accessor_str.py | 27 ++++++++++++------ xarray/tests/test_accessor_str.py | 46 +++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index 712971192a4..348b457f285 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -168,6 +168,11 @@ Bug Fixes the array. Previously, e.g. ``da.cumulative("time").argmax()`` returned indices shifted by the number of missing values in front of the array (:issue:`11336`). By `Michael Niklas `_. +- ``DataArray.str.replace`` now respects ``case=False`` when ``regex=False``; + the option was silently ignored. ``DataArray.str.contains`` with + ``case=False, regex=False`` no longer raises ``AttributeError`` on object-dtype + or ``StringDType`` arrays. + By `Novain Shibu `_. - Fix :py:func:`tutorial.open_dataset` and :py:func:`tutorial.open_datatree` with ``cache=False`` failing on Windows, because the downloaded file was removed while still open (:pull:`11703`). diff --git a/xarray/core/accessor_str.py b/xarray/core/accessor_str.py index 103de9e20c1..fb13f53d70d 100644 --- a/xarray/core/accessor_str.py +++ b/xarray/core/accessor_str.py @@ -1520,14 +1520,10 @@ def func(x, ipat): pat = self._stringify(pat) if case or case is None: func = lambda x, ipat: ipat in x - elif self._obj.dtype.char == "U": - uppered = self.casefold() - uppat = StringAccessor(pat).casefold() # type: ignore[type-var] # hack? - return uppered.str.contains(uppat, regex=False) # type: ignore[return-value] + elif self._obj.dtype.kind == "S": + func = lambda x, ipat: ipat.upper() in x.upper() else: - uppered = self.upper() - uppat = StringAccessor(pat).upper() # type: ignore[type-var] # hack? - return uppered.str.contains(uppat, regex=False) # type: ignore[return-value] + func = lambda x, ipat: ipat.casefold() in x.casefold() return self._apply(func=func, func_args=(pat,), dtype=bool) @@ -1962,7 +1958,22 @@ def replace( ) else: pat = self._stringify(pat) - func = lambda x, ipat, irepl, i_n: x.replace(ipat, irepl, i_n) + if case is False: + # Literal match, but case-insensitive: escape the pattern and + # return the replacement as-is so it is not parsed as a template. + func = lambda x, ipat, irepl, i_n: ( + x + if i_n == 0 + else re.sub( + re.escape(ipat), + lambda _: irepl, + x, + count=max(i_n, 0), + flags=re.IGNORECASE, + ) + ) + else: + func = lambda x, ipat, irepl, i_n: x.replace(ipat, irepl, i_n) return self._apply(func=func, func_args=(pat, repl, n)) def extract( diff --git a/xarray/tests/test_accessor_str.py b/xarray/tests/test_accessor_str.py index 55ba2b55f5e..912e479a98a 100644 --- a/xarray/tests/test_accessor_str.py +++ b/xarray/tests/test_accessor_str.py @@ -403,6 +403,52 @@ def test_replace_n_zero(dtype) -> None: assert_equal(result, expected) +def test_replace_literal_case_insensitive(dtype) -> None: + values = xr.DataArray(["fooBAD.bad__barbAd"], dims=["x"]).astype(dtype) + + result = values.str.replace("bad", "X", case=False, regex=False) + expected = xr.DataArray(["fooX.X__barX"], dims=["x"]).astype(dtype) + assert result.dtype == expected.dtype + assert_equal(result, expected) + + # the pattern is literal, and the replacement is not a regex template + result = values.str.replace("D.B", r"", n=1, case=False, regex=False) + expected = xr.DataArray([r"fooBAad__barbAd"], dims=["x"]).astype(dtype) + assert result.dtype == expected.dtype + assert_equal(result, expected) + + result = values.str.replace("bad", "X", n=2, case=False, regex=False) + expected = xr.DataArray(["fooX.X__barbAd"], dims=["x"]).astype(dtype) + assert_equal(result, expected) + + result = values.str.replace("bad", "X", n=0, case=False, regex=False) + assert_equal(result, values) + + +@pytest.mark.parametrize( + "values", + [ + pytest.param(np.array(["Foo", "xYz", "fOO.oo"], dtype=object), id="object"), + pytest.param( + np.array(["Foo", "xYz", "fOO.oo"], dtype=np.dtypes.StringDType()), + id="StringDType", + marks=pytest.mark.skipif( + not hasattr(np, "dtypes") or not hasattr(np.dtypes, "StringDType"), + reason="requires numpy>=2", + ), + ), + ], +) +def test_case_insensitive_literal_non_unicode_dtype(values) -> None: + da = xr.DataArray(values, dims=["x"]) + + result = da.str.contains("o.", case=False, regex=False) + assert_equal(result, xr.DataArray([False, False, True], dims=["x"])) + + result = da.str.replace("o", "_", case=False, regex=False) + assert list(result.values) == ["F__", "xYz", "f__.__"] + + def test_replace_callable() -> None: values = xr.DataArray(["fooBAD__barBAD"]) From 61974cec8ded1c39688119dfde85922867342b2d Mon Sep 17 00:00:00 2001 From: Novain Shibu Date: Fri, 16 Oct 2026 19:08:18 +0530 Subject: [PATCH 2/3] Add PR number to whats-new entry Co-Authored-By: Claude Opus 5.5 Signed-off-by: Novain Shibu --- doc/whats-new.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index 348b457f285..b9052ce240a 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -171,7 +171,7 @@ Bug Fixes - ``DataArray.str.replace`` now respects ``case=False`` when ``regex=False``; the option was silently ignored. ``DataArray.str.contains`` with ``case=False, regex=False`` no longer raises ``AttributeError`` on object-dtype - or ``StringDType`` arrays. + or ``StringDType`` arrays (:pull:`11747`). By `Novain Shibu `_. - Fix :py:func:`tutorial.open_dataset` and :py:func:`tutorial.open_datatree` with ``cache=False`` failing on Windows, because the downloaded file was From 9b28876c117e74be318147099eec03a6eeee5d59 Mon Sep 17 00:00:00 2001 From: Novain Shibu Date: Sat, 17 Oct 2026 19:20:29 +0530 Subject: [PATCH 3/3] Address review: doc roles, drop numpy skip, use assert_equal Use :py:meth: roles in the whats-new entry, remove the numpy>=2 skipif (minimum numpy is 2.2), and compare the replace result with assert_equal. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Novain Shibu --- doc/whats-new.rst | 4 ++-- xarray/tests/test_accessor_str.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index b9052ce240a..4df2ffd1db7 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -168,8 +168,8 @@ Bug Fixes the array. Previously, e.g. ``da.cumulative("time").argmax()`` returned indices shifted by the number of missing values in front of the array (:issue:`11336`). By `Michael Niklas `_. -- ``DataArray.str.replace`` now respects ``case=False`` when ``regex=False``; - the option was silently ignored. ``DataArray.str.contains`` with +- :py:meth:`DataArray.str.replace` now respects ``case=False`` when ``regex=False``; + the option was silently ignored. :py:meth:`DataArray.str.contains` with ``case=False, regex=False`` no longer raises ``AttributeError`` on object-dtype or ``StringDType`` arrays (:pull:`11747`). By `Novain Shibu `_. diff --git a/xarray/tests/test_accessor_str.py b/xarray/tests/test_accessor_str.py index 912e479a98a..e71558aaca1 100644 --- a/xarray/tests/test_accessor_str.py +++ b/xarray/tests/test_accessor_str.py @@ -432,10 +432,6 @@ def test_replace_literal_case_insensitive(dtype) -> None: pytest.param( np.array(["Foo", "xYz", "fOO.oo"], dtype=np.dtypes.StringDType()), id="StringDType", - marks=pytest.mark.skipif( - not hasattr(np, "dtypes") or not hasattr(np.dtypes, "StringDType"), - reason="requires numpy>=2", - ), ), ], ) @@ -446,7 +442,11 @@ def test_case_insensitive_literal_non_unicode_dtype(values) -> None: assert_equal(result, xr.DataArray([False, False, True], dims=["x"])) result = da.str.replace("o", "_", case=False, regex=False) - assert list(result.values) == ["F__", "xYz", "f__.__"] + expected = xr.DataArray( + np.array(["F__", "xYz", "f__.__"], dtype=values.dtype), dims=["x"] + ) + assert result.dtype == expected.dtype + assert_equal(result, expected) def test_replace_callable() -> None: