Repository: narwhals-dev/narwhals
Pull request #605 (2024-07-23 20:28:58)

feat: `any_horizontal` function
## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Checklist

- [x] Code follows style guide (ruff)
- [x] Tests added 
- [x] Documented the changes




Patch:
diff --git a/docs/api-reference/narwhals.md b/docs/api-reference/narwhals.md
index 6bef0776..16bc6621 100644
--- a/docs/api-reference/narwhals.md
+++ b/docs/api-reference/narwhals.md
@@ -8,6 +8,7 @@ Here are the top-level functions available in Narwhals.
       members:
         - all
         - all_horizontal
+        - any_horizontal
         - col
         - concat
         - from_dict
diff --git a/narwhals/__init__.py b/narwhals/__init__.py
index d057826c..15689d99 100644
--- a/narwhals/__init__.py
+++ b/narwhals/__init__.py
@@ -24,6 +24,7 @@ from narwhals.dtypes import Unknown
 from narwhals.expr import Expr
 from narwhals.expr import all
 from narwhals.expr import all_horizontal
+from narwhals.expr import any_horizontal
 from narwhals.expr import col
 from narwhals.expr import len
 from narwhals.expr import lit
@@ -63,6 +64,7 @@ __all__ = [
     "get_native_namespace",
     "all",
     "all_horizontal",
+    "any_horizontal",
     "col",
     "len",
     "lit",
diff --git a/narwhals/_arrow/namespace.py b/narwhals/_arrow/namespace.py
index b7888523..37f39d43 100644
--- a/narwhals/_arrow/namespace.py
+++ b/narwhals/_arrow/namespace.py
@@ -170,6 +170,12 @@ class ArrowNamespace:
             parse_into_exprs(*exprs, namespace=self),
         )
 
+    def any_horizontal(self, *exprs: IntoArrowExpr) -> ArrowExpr:
+        return reduce(
+            lambda x, y: x | y,
+            parse_into_exprs(*exprs, namespace=self),
+        )
+
     def sum_horizontal(self, *exprs: IntoArrowExpr) -> ArrowExpr:
         return reduce(
             lambda x, y: x + y,
diff --git a/narwhals/_pandas_like/namespace.py b/narwhals/_pandas_like/namespace.py
index 9c82a8ab..cd87070c 100644
--- a/narwhals/_pandas_like/namespace.py
+++ b/narwhals/_pandas_like/namespace.py
@@ -211,11 +211,16 @@ class PandasLikeNamespace:
         )
 
     def all_horizontal(self, *exprs: IntoPandasLikeExpr) -> PandasLikeExpr:
-        # Why is this showing up as uncovered? It defo is?
         return reduce(
             lambda x, y: x & y,
             parse_into_exprs(*exprs, namespace=self),
-        )  # pragma: no cover
+        )
+
+    def any_horizontal(self, *exprs: IntoPandasLikeExpr) -> PandasLikeExpr:
+        return reduce(
+            lambda x, y: x | y,
+            parse_into_exprs(*exprs, namespace=self),
+        )
 
     def concat(
         self,
diff --git a/narwhals/expr.py b/narwhals/expr.py
index 5354b83f..6a619241 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -3723,6 +3723,66 @@ def lit(value: Any, dtype: DType | None = None) -> Expr:
     return Expr(lambda plx: plx.lit(value, translate_dtype(plx, dtype)))
 
 
+def any_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
+    r"""
+    Compute the bitwise OR horizontally across columns.
+
+    Arguments:
+        exprs: Name(s) of the columns to use in the aggregation function. Accepts expression input.
+
+    Notes:
+        pandas and Polars handle null values differently.
+
+    Examples:
+        >>> import pandas as pd
+        >>> import polars as pl
+        >>> import narwhals as nw
+        >>> data = {
+        ...     "a": [False, False, True, True, False, None],
+        ...     "b": [False, True, True, None, None, None],
+        ... }
+        >>> df_pl = pl.DataFrame(data)
+        >>> df_pd = pd.DataFrame(data)
+
+        We define a dataframe-agnostic function:
+
+        >>> @nw.narwhalify
+        ... def func(df_any):
+        ...     return df_any.select("a", "b", any=nw.any_horizontal("a", "b"))
+
+        We can then pass either pandas or polars to `func`:
+
+        >>> func(df_pd)
+               a      b    any
+        0  False  False  False
+        1  False   True   True
+        2   True   True   True
+        3   True   None   True
+        4  False   None  False
+        5   None   None  False
+
+        >>> func(df_pl)
+        shape: (6, 3)
+        ┌───────┬───────┬───────┐
+        │ a     ┆ b     ┆ any   │
+        │ ---   ┆ ---   ┆ ---   │
+        │ bool  ┆ bool  ┆ bool  │
+        ╞═══════╪═══════╪═══════╡
+        │ false ┆ false ┆ false │
+        │ false ┆ true  ┆ true  │
+        │ true  ┆ true  ┆ true  │
+        │ true  ┆ null  ┆ true  │
+        │ false ┆ null  ┆ null  │
+        │ null  ┆ null  ┆ null  │
+        └───────┴───────┴───────┘
+    """
+    return Expr(
+        lambda plx: plx.any_horizontal(
+            *[extract_compliant(plx, v) for v in flatten(exprs)]
+        )
+    )
+
+
 __all__ = [
     "Expr",
 ]
diff --git a/narwhals/stable/v1.py b/narwhals/stable/v1.py
index 8215c5f4..b53596e3 100644
--- a/narwhals/stable/v1.py
+++ b/narwhals/stable/v1.py
@@ -1249,6 +1249,62 @@ def all_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
     return _stableify(nw.all_horizontal(*exprs))
 
 
+def any_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
+    r"""
+    Compute the bitwise OR horizontally across columns.
+
+    Arguments:
+        exprs: Name(s) of the columns to use in the aggregation function. Accepts expression input.
+
+    Notes:
+        pandas and Polars handle null values differently.
+
+    Examples:
+        >>> import pandas as pd
+        >>> import polars as pl
+        >>> import narwhals.stable.v1 as nw
+        >>> data = {
+        ...     "a": [False, False, True, True, False, None],
+        ...     "b": [False, True, True, None, None, None],
+        ... }
+        >>> df_pl = pl.DataFrame(data)
+        >>> df_pd = pd.DataFrame(data)
+
+        We define a dataframe-agnostic function:
+
+        >>> @nw.narwhalify
+        ... def func(df_any):
+        ...     return df_any.select("a", "b", any=nw.any_horizontal("a", "b"))
+
+        We can then pass either pandas or polars to `func`:
+
+        >>> func(df_pd)
+               a      b    any
+        0  False  False  False
+        1  False   True   True
+        2   True   True   True
+        3   True   None   True
+        4  False   None  False
+        5   None   None  False
+
+        >>> func(df_pl)
+        shape: (6, 3)
+        ┌───────┬───────┬───────┐
+        │ a     ┆ b     ┆ any   │
+        │ ---   ┆ ---   ┆ ---   │
+        │ bool  ┆ bool  ┆ bool  │
+        ╞═══════╪═══════╪═══════╡
+        │ false ┆ false ┆ false │
+        │ false ┆ true  ┆ true  │
+        │ true  ┆ true  ┆ true  │
+        │ true  ┆ null  ┆ true  │
+        │ false ┆ null  ┆ null  │
+        │ null  ┆ null  ┆ null  │
+        └───────┴───────┴───────┘
+    """
+    return _stableify(nw.any_horizontal(*exprs))
+
+
 def is_ordered_categorical(series: Series) -> bool:
     """
     Return whether indices of categories are semantically meaningful.
@@ -1458,6 +1514,7 @@ __all__ = [
     "get_level",
     "all",
     "all_horizontal",
+    "any_horizontal",
     "col",
     "len",
     "lit",


Test patch:
diff --git a/tests/expr_and_series/any_horizontal_test.py b/tests/expr_and_series/any_horizontal_test.py
new file mode 100644
index 00000000..df5b35eb
--- /dev/null
+++ b/tests/expr_and_series/any_horizontal_test.py
@@ -0,0 +1,20 @@
+from typing import Any
+
+import numpy as np
+import pytest
+
+import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
+
+
+@pytest.mark.parametrize("col_expr", [np.array([False, False, True]), nw.col("a"), "a"])
+def test_anyh(constructor: Any, col_expr: Any) -> None:
+    data = {
+        "a": [False, False, True],
+        "b": [False, True, True],
+    }
+    df = nw.from_native(constructor(data), eager_only=True)
+    result = df.select(any=nw.any_horizontal(col_expr, nw.col("b")))
+
+    expected = {"any": [False, True, True]}
+    compare_dicts(result, expected)