Repository: narwhals-dev/narwhals
Pull request #502 (2024-07-12 20:03:14)

feat: expose `all_horizontal`
## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [x] ✨ Feature
- [x] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Checklist

- [x] Code follows style guide (ruff)
- [x] Tests added 
- [x] Documented the changes

## If you have comments or can explain your changes, please do so below.

Remark that polars and pandas `__and__` logic is different for None.

I fixed the implementation for pyarrow however!


Patch:
diff --git a/docs/api-reference/narwhals.md b/docs/api-reference/narwhals.md
index f8154a14..06af4ddf 100644
--- a/docs/api-reference/narwhals.md
+++ b/docs/api-reference/narwhals.md
@@ -7,6 +7,7 @@ Here are the top-level functions available in Narwhals.
     options:
       members:
         - all
+        - all_horizontal
         - col
         - concat
         - from_native
diff --git a/narwhals/__init__.py b/narwhals/__init__.py
index f0d116a8..30f6d1c7 100644
--- a/narwhals/__init__.py
+++ b/narwhals/__init__.py
@@ -23,6 +23,7 @@ from narwhals.dtypes import UInt64
 from narwhals.dtypes import Unknown
 from narwhals.expression import Expr
 from narwhals.expression import all
+from narwhals.expression import all_horizontal
 from narwhals.expression import col
 from narwhals.expression import len
 from narwhals.expression import lit
@@ -56,6 +57,7 @@ __all__ = [
     "maybe_set_index",
     "get_native_namespace",
     "all",
+    "all_horizontal",
     "col",
     "len",
     "lit",
diff --git a/narwhals/_arrow/namespace.py b/narwhals/_arrow/namespace.py
index 0a7d3453..25852327 100644
--- a/narwhals/_arrow/namespace.py
+++ b/narwhals/_arrow/namespace.py
@@ -125,3 +125,12 @@ class ArrowNamespace:
             lambda x, y: x & y,
             parse_into_exprs(*exprs, namespace=self),
         )  # pragma: no cover
+
+    def sum_horizontal(self, *exprs: IntoArrowExpr) -> ArrowExpr:
+        return reduce(
+            lambda x, y: x + y,
+            parse_into_exprs(
+                *exprs,
+                namespace=self,
+            ),
+        )
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 33f5669d..0b732980 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -95,13 +95,13 @@ class ArrowSeries:
         pc = get_pyarrow_compute()
         ser = self._native_series
         other = validate_column_comparand(other)
-        return self._from_native_series(pc.and_(ser, other))
+        return self._from_native_series(pc.and_kleene(ser, other))
 
     def __or__(self, other: Any) -> Self:
         pc = get_pyarrow_compute()
         ser = self._native_series
         other = validate_column_comparand(other)
-        return self._from_native_series(pc.or_(ser, other))
+        return self._from_native_series(pc.or_kleene(ser, other))
 
     def __add__(self, other: Any) -> Self:
         pc = get_pyarrow_compute()
diff --git a/narwhals/expression.py b/narwhals/expression.py
index 79013039..02f7644d 100644
--- a/narwhals/expression.py
+++ b/narwhals/expression.py
@@ -3344,13 +3344,70 @@ def sum_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
         │ 12  │
         │ 18  │
         └─────┘
-
     """
     return Expr(
         lambda plx: plx.sum_horizontal([extract_native(plx, v) for v in flatten(exprs)])
     )
 
 
+def all_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
+    r"""
+    Compute the bitwise AND 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):
+        ...     return df.select("a", "b", all=nw.all_horizontal("a", "b"))
+
+        We can then pass either pandas or polars to `func`:
+
+        >>> func(df_pd)
+               a      b    all
+        0  False  False  False
+        1  False   True  False
+        2   True   True   True
+        3   True   None  False
+        4  False   None  False
+        5   None   None  False
+
+        >>> func(df_pl)
+        shape: (6, 3)
+        ┌───────┬───────┬───────┐
+        │ a     ┆ b     ┆ all   │
+        │ ---   ┆ ---   ┆ ---   │
+        │ bool  ┆ bool  ┆ bool  │
+        ╞═══════╪═══════╪═══════╡
+        │ false ┆ false ┆ false │
+        │ false ┆ true  ┆ false │
+        │ true  ┆ true  ┆ true  │
+        │ true  ┆ null  ┆ null  │
+        │ false ┆ null  ┆ false │
+        │ null  ┆ null  ┆ null  │
+        └───────┴───────┴───────┘
+    """
+    return Expr(
+        lambda plx: plx.all_horizontal([extract_native(plx, v) for v in flatten(exprs)])
+    )
+
+
 def lit(value: Any, dtype: DType | None = None) -> Expr:
     """
     Return an expression representing a literal value.
diff --git a/narwhals/stable/v1.py b/narwhals/stable/v1.py
index e27a4948..94cceaa6 100644
--- a/narwhals/stable/v1.py
+++ b/narwhals/stable/v1.py
@@ -1108,11 +1108,66 @@ def sum_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
         │ 12  │
         │ 18  │
         └─────┘
-
     """
     return _stableify(nw.sum_horizontal(*exprs))
 
 
+def all_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
+    r"""
+    Compute the bitwise AND 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):
+        ...     return df.select("a", "b", all=nw.all_horizontal("a", "b"))
+
+        We can then pass either pandas or polars to `func`:
+
+        >>> func(df_pd)
+               a      b    all
+        0  False  False  False
+        1  False   True  False
+        2   True   True   True
+        3   True   None  False
+        4  False   None  False
+        5   None   None  False
+
+        >>> func(df_pl)
+        shape: (6, 3)
+        ┌───────┬───────┬───────┐
+        │ a     ┆ b     ┆ all   │
+        │ ---   ┆ ---   ┆ ---   │
+        │ bool  ┆ bool  ┆ bool  │
+        ╞═══════╪═══════╪═══════╡
+        │ false ┆ false ┆ false │
+        │ false ┆ true  ┆ false │
+        │ true  ┆ true  ┆ true  │
+        │ true  ┆ null  ┆ null  │
+        │ false ┆ null  ┆ false │
+        │ null  ┆ null  ┆ null  │
+        └───────┴───────┴───────┘
+    """
+    return _stableify(nw.all_horizontal(*exprs))
+
+
 def is_ordered_categorical(series: Series) -> bool:
     """
     Return whether indices of categories are semantically meaningful.
@@ -1264,6 +1319,7 @@ __all__ = [
     "maybe_set_index",
     "get_native_namespace",
     "all",
+    "all_horizontal",
     "col",
     "len",
     "lit",


Test patch:
diff --git a/tests/expr/all_horizontal_test.py b/tests/expr/all_horizontal_test.py
new file mode 100644
index 00000000..a094ad79
--- /dev/null
+++ b/tests/expr/all_horizontal_test.py
@@ -0,0 +1,19 @@
+from typing import Any
+
+import pytest
+
+import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
+
+
+@pytest.mark.parametrize("col_expr", [nw.col("a"), "a"])
+def test_allh(constructor_with_pyarrow: Any, col_expr: Any) -> None:
+    data = {
+        "a": [False, False, True],
+        "b": [False, True, True],
+    }
+    df = nw.from_native(constructor_with_pyarrow(data), eager_only=True)
+    result = df.select(all=nw.all_horizontal(col_expr, nw.col("b")))
+
+    expected = {"all": [False, False, True]}
+    compare_dicts(result, expected)
diff --git a/tests/expr/sum_horizontal_test.py b/tests/expr/sum_horizontal_test.py
index 3c4600bb..64e74308 100644
--- a/tests/expr/sum_horizontal_test.py
+++ b/tests/expr/sum_horizontal_test.py
@@ -1,32 +1,20 @@
 from typing import Any
 
+import pytest
+
 import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 
-def test_sumh(constructor: Any) -> None:
-    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
-    df = nw.from_native(constructor(data), eager_only=True)
-    result = df.with_columns(horizonal_sum=nw.sum_horizontal(nw.col("a"), nw.col("b")))
-    result_native = nw.to_native(result)
-    expected = {
-        "a": [1, 3, 2],
-        "b": [4, 4, 6],
-        "z": [7.0, 8.0, 9.0],
-        "horizonal_sum": [5, 7, 8],
-    }
-    compare_dicts(result_native, expected)
-
-
-def test_sumh_literal(constructor: Any) -> None:
+@pytest.mark.parametrize("col_expr", [nw.col("a"), "a"])
+def test_sumh(constructor_with_pyarrow: Any, col_expr: Any) -> None:
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
-    df = nw.from_native(constructor(data), eager_only=True)
-    result = df.with_columns(horizonal_sum=nw.sum_horizontal("a", nw.col("b")))
-    result_native = nw.to_native(result)
+    df = nw.from_native(constructor_with_pyarrow(data), eager_only=True)
+    result = df.with_columns(horizontal_sum=nw.sum_horizontal(col_expr, nw.col("b")))
     expected = {
         "a": [1, 3, 2],
         "b": [4, 4, 6],
         "z": [7.0, 8.0, 9.0],
-        "horizonal_sum": [5, 7, 8],
+        "horizontal_sum": [5, 7, 8],
     }
-    compare_dicts(result_native, expected)
+    compare_dicts(result, expected)