Repository: narwhals-dev/narwhals
Pull request #300 (2024-06-14 13:02:40)

Expression String Method - Contains
<!--
# Thanks for contributing a pull request! 
## Please make sure you see our contribution guidelines: https://github.com/narwhals-dev/narwhals/blob/main/CONTRIBUTING.md
-->

## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues 

- Related issue # 
- Closes #

## Checklist

- [x] Code follows style guide (ruff)
- [x] Tests added 
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below.



FBruzzesi: Very cool feature! I was looking forward to this one! Thanks for implementing it ✨
ugohuche: > Very cool feature! I was looking forward to this one! Thanks for implementing it ✨

I was more than happy to do it. 😊
ugohuche: > thanks @ugohuche ! can you add a test please? it should go into `tests/expr/str/contains_test`

Will do

Patch:
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index a7e167f0..97c7f821 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -315,6 +315,15 @@ class PandasExprStringNamespace:
             suffix,
         )
 
+    def contains(self, pattern: str, *, literal: bool) -> PandasExpr:
+        return reuse_series_namespace_implementation(
+            self._expr,
+            "str",
+            "contains",
+            pattern,
+            literal=literal,
+        )
+
     def head(self, n: int = 5) -> PandasExpr:
         return reuse_series_namespace_implementation(
             self._expr,
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index e1e6cd30..61d61fbc 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -518,6 +518,11 @@ class PandasSeriesStringNamespace:
             self._series._series.str.endswith(suffix),
         )
 
+    def contains(self, pattern: str, *, literal: bool = False) -> PandasSeries:
+        return self._series._from_series(
+            self._series._series.str.contains(pat=pattern, regex=not literal)
+        )
+
     def head(self, n: int = 5) -> PandasSeries:
         return self._series._from_series(
             self._series._series.str[:n],
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index 6b5c26f7..71d05556 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -652,25 +652,33 @@ class DataFrame(BaseFrame):
             existing data.
 
         Examples:
-            Pass an expression to add it as a new column.
-
+            >>> import pandas as pd
             >>> import polars as pl
             >>> import narwhals as nw
-            >>> df_pl = pl.DataFrame(
-            ...     {
-            ...         "a": [1, 2, 3, 4],
-            ...         "b": [0.5, 4, 10, 13],
-            ...         "c": [True, True, False, True],
-            ...     }
-            ... )
-            >>> df = nw.from_native(df_pl)
-            >>> dframe = df.with_columns((nw.col("a") * 2).alias("a*2"))
-            >>> dframe
-            ┌───────────────────────────────────────────────┐
-            | Narwhals DataFrame                            |
-            | Use `narwhals.to_native` to see native output |
-            └───────────────────────────────────────────────┘
-            >>> nw.to_native(dframe)
+            >>> df = {
+            ...     "a": [1, 2, 3, 4],
+            ...     "b": [0.5, 4, 10, 13],
+            ...     "c": [True, True, False, True],
+            ... }
+            >>> df_pd = pd.DataFrame(df)
+            >>> df_pl = pl.DataFrame(df)
+
+            Let's define a dataframe-agnostic function in which we pass an expression
+            to add it as a new column:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns((nw.col("a") * 2).alias("a*2"))
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(df_pd)
+               a     b      c  a*2
+            0  1   0.5   True    2
+            1  2   4.0   True    4
+            2  3  10.0  False    6
+            3  4  13.0   True    8
+            >>> func(df_pl)
             shape: (4, 4)
             ┌─────┬──────┬───────┬─────┐
             │ a   ┆ b    ┆ c     ┆ a*2 │
@@ -1861,25 +1869,46 @@ class LazyFrame(BaseFrame):
             existing data.
 
         Examples:
-            Pass an expression to add it as a new column.
-
+            >>> import pandas as pd
             >>> import polars as pl
             >>> import narwhals as nw
-            >>> lf_pl = pl.LazyFrame(
-            ...     {
-            ...         "a": [1, 2, 3, 4],
-            ...         "b": [0.5, 4, 10, 13],
-            ...         "c": [True, True, False, True],
-            ...     }
-            ... )
-            >>> lf = nw.LazyFrame(lf_pl)
-            >>> lframe = lf.with_columns((nw.col("a") * 2).alias("2a")).collect()
-            >>> lframe
-            ┌───────────────────────────────────────────────┐
-            | Narwhals DataFrame                            |
-            | Use `narwhals.to_native` to see native output |
-            └───────────────────────────────────────────────┘
-            >>> nw.to_native(lframe)
+            >>> df = {
+            ...     "a": [1, 2, 3, 4],
+            ...     "b": [0.5, 4, 10, 13],
+            ...     "c": [True, True, False, True],
+            ... }
+            >>> df_pd = pd.DataFrame(df)
+            >>> df_pl = pl.DataFrame(df)
+            >>> lf_pl = pl.LazyFrame(df)
+
+            Let's define a dataframe-agnostic function in which we pass an expression
+            to add it as a new column:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns((nw.col("a") * 2).alias("2a"))
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(df_pd)
+               a     b      c  2a
+            0  1   0.5   True   2
+            1  2   4.0   True   4
+            2  3  10.0  False   6
+            3  4  13.0   True   8
+            >>> func(df_pl)
+            shape: (4, 4)
+            ┌─────┬──────┬───────┬─────┐
+            │ a   ┆ b    ┆ c     ┆ 2a  │
+            │ --- ┆ ---  ┆ ---   ┆ --- │
+            │ i64 ┆ f64  ┆ bool  ┆ i64 │
+            ╞═════╪══════╪═══════╪═════╡
+            │ 1   ┆ 0.5  ┆ true  ┆ 2   │
+            │ 2   ┆ 4.0  ┆ true  ┆ 4   │
+            │ 3   ┆ 10.0 ┆ false ┆ 6   │
+            │ 4   ┆ 13.0 ┆ true  ┆ 8   │
+            └─────┴──────┴───────┴─────┘
+            >>> func(lf_pl).collect()
             shape: (4, 4)
             ┌─────┬──────┬───────┬─────┐
             │ a   ┆ b    ┆ c     ┆ 2a  │
diff --git a/narwhals/expression.py b/narwhals/expression.py
index 3ee39781..d6829c3c 100644
--- a/narwhals/expression.py
+++ b/narwhals/expression.py
@@ -1537,6 +1537,65 @@ class ExprStringNamespace:
             lambda plx: self._expr._call(plx).str.ends_with(suffix)
         )
 
+    def contains(self, pattern: str, *, literal: bool = False) -> Expr:
+        """
+        Check if string contains a substring that matches a pattern.
+
+        Arguments:
+            pattern: A Character sequence or valid regular expression pattern.
+
+            literal: If True, treats the pattern as a literal string.
+                     If False, assumes the pattern is a regular expression.
+
+        Example:
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import narwhals as nw
+            >>> data = {"pets": ["cat", "dog", "rabbit and parrot", "dove", None]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+
+            We define a dataframe-agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.with_columns(
+            ...         default_match=nw.col("pets").str.contains("parrot|Dove"),
+            ...         case_insensitive_match=nw.col("pets").str.contains("(?i)parrot|Dove"),
+            ...         literal_match=nw.col("pets").str.contains(
+            ...             "parrot|Dove", literal=True
+            ...         ),
+            ...     )
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(df_pd)
+                            pets default_match case_insensitive_match literal_match
+            0                cat         False                  False         False
+            1                dog         False                  False         False
+            2  rabbit and parrot          True                   True         False
+            3               dove         False                   True         False
+            4               None          None                   None          None
+            >>> func(df_pl)
+            shape: (5, 4)
+            ┌───────────────────┬───────────────┬────────────────────────┬───────────────┐
+            │ pets              ┆ default_match ┆ case_insensitive_match ┆ literal_match │
+            │ ---               ┆ ---           ┆ ---                    ┆ ---           │
+            │ str               ┆ bool          ┆ bool                   ┆ bool          │
+            ╞═══════════════════╪═══════════════╪════════════════════════╪═══════════════╡
+            │ cat               ┆ false         ┆ false                  ┆ false         │
+            │ dog               ┆ false         ┆ false                  ┆ false         │
+            │ rabbit and parrot ┆ true          ┆ true                   ┆ false         │
+            │ dove              ┆ false         ┆ true                   ┆ false         │
+            │ null              ┆ null          ┆ null                   ┆ null          │
+            └───────────────────┴───────────────┴────────────────────────┴───────────────┘
+
+        """
+
+        return self._expr.__class__(
+            lambda plx: self._expr._call(plx).str.contains(pattern, literal=literal)
+        )
+
     def head(self, n: int = 5) -> Expr:
         """
         Take the first n elements of each string.
diff --git a/narwhals/series.py b/narwhals/series.py
index 81477565..1adc55e4 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -1723,6 +1723,54 @@ class SeriesStringNamespace:
     def ends_with(self, suffix: str) -> Series:
         return self._series.__class__(self._series._series.str.ends_with(suffix))
 
+    def contains(self, pattern: str, *, literal: bool = False) -> Series:
+        """
+        Check if string contains a substring that matches a pattern.
+
+        Arguments:
+            pattern: A Character sequence or valid regular expression pattern.
+
+            literal: If True, treats the pattern as a literal string.
+                     If False, assumes the pattern is a regular expression.
+
+        Example:
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import narwhals as nw
+            >>> pets = ["cat", "dog", "rabbit and parrot", "dove", None]
+            >>> s_pd = pd.Series(pets)
+            >>> s_pl = pl.Series(pets)
+
+            We define a dataframe-agnostic function:
+
+            >>> @nw.narwhalify(series_only=True)
+            ... def func(s):
+            ...     return s.str.contains("parrot|dove")
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(s_pd)
+            0    False
+            1    False
+            2     True
+            3     True
+            4     None
+            dtype: object
+            >>> func(s_pl)  # doctest: +NORMALIZE_WHITESPACE
+            shape: (5,)
+            Series: '' [bool]
+            [
+               false
+               false
+               true
+               true
+               null
+            ]
+        """
+        return self._series.__class__(
+            self._series._series.str.contains(pattern, literal=literal)
+        )
+
     def head(self, n: int = 5) -> Series:
         """
         Take the first n elements of each string.


Test patch:
diff --git a/tests/expr/str/contains_test.py b/tests/expr/str/contains_test.py
new file mode 100644
index 00000000..e38c1be0
--- /dev/null
+++ b/tests/expr/str/contains_test.py
@@ -0,0 +1,30 @@
+from typing import Any
+
+import pandas as pd
+import polars as pl
+import pytest
+
+import narwhals as nw
+from tests.utils import compare_dicts
+
+data = {"pets": ["cat", "dog", "rabbit and parrot", "dove"]}
+
+df_pandas = pd.DataFrame(data)
+df_polars = pl.DataFrame(data)
+
+
+@pytest.mark.parametrize("df_any", [df_pandas, df_polars])
+def test_contains(df_any: Any) -> None:
+    df = nw.from_native(df_any, eager_only=True)
+    result = df.with_columns(
+        case_insensitive_match=nw.col("pets").str.contains("(?i)parrot|Dove")
+    )
+    expected = {
+        "pets": ["cat", "dog", "rabbit and parrot", "dove"],
+        "case_insensitive_match": [False, False, True, True],
+    }
+    compare_dicts(result, expected)
+    result = df.with_columns(
+        case_insensitive_match=df["pets"].str.contains("(?i)parrot|Dove")
+    )
+    compare_dicts(result, expected)