Repository: narwhals-dev/narwhals

Issue:
feat: PyArrow table support
It might be possible to support PyArrow tables in Narwhals - investigations / implementations would be very welcome!

Discussion:

Patch:
diff --git a/README.md b/README.md
index 00dba4c3..bd85b56b 100644
--- a/README.md
+++ b/README.md
@@ -11,8 +11,8 @@
 
 Extremely lightweight and extensible compatibility layer between dataframe libraries!
 
-- **Full API support**: cuDF, Modin, pandas, Polars
-- **Interchange-level support**: Ibis, PyArrow, Vaex, anything else which implements the DataFrame Interchange Protocol
+- **Full API support**: cuDF, Modin, pandas, Polars, PyArrow
+- **Interchange-level support**: Ibis, Vaex, anything else which implements the DataFrame Interchange Protocol
 
 Seamlessly support all, without depending on any!
 
@@ -62,7 +62,7 @@ Feel free to add your project to the list if it's missing, and/or
 
 There are three steps to writing dataframe-agnostic code using Narwhals:
 
-1. use `narwhals.from_native` to wrap a pandas/Polars/Modin/cuDF
+1. use `narwhals.from_native` to wrap a pandas/Polars/Modin/cuDF/PyArrow
    DataFrame/LazyFrame in a Narwhals class
 2. use the [subset of the Polars API supported by Narwhals](https://narwhals-dev.github.io/narwhals/api-reference/)
 3. use `narwhals.to_native` to return an object to the user in its original
@@ -72,6 +72,7 @@ There are three steps to writing dataframe-agnostic code using Narwhals:
    - if you started with Polars, you'll get Polars back
    - if you started with Modin, you'll get Modin back (and compute will be distributed)
    - if you started with cuDF, you'll get cuDF back (and compute will happen on GPU)
+   - if you started with PyArrow, you'll get PyArrow back (and compute will happen on GPU)
 
 ## Example
 
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/docs/extending.md b/docs/extending.md
index a72d08df..dc0d4671 100644
--- a/docs/extending.md
+++ b/docs/extending.md
@@ -6,7 +6,7 @@ Currently, Narwhals supports the following libraries as inputs:
 - Polars
 - cuDF
 - Modin
-- PyArrow (partial support)
+- PyArrow
 
 If you want your own library to be recognised too, you're welcome open a PR (with tests)!
 Alternatively, if you can't do that (for example, if you library is closed-source), see
diff --git a/docs/levels.md b/docs/levels.md
index bfa580f1..5a5b87f2 100644
--- a/docs/levels.md
+++ b/docs/levels.md
@@ -18,7 +18,7 @@ def func(df: FrameT) -> FrameT:
         b_std=nw.col('b').std(),
     )
 ```
-will work for any of pandas, Polars, cuDF, and Modin.
+will work for any of pandas, Polars, cuDF, Modin, and PyArrow.
 
 However, sometimes you don't need to do complex operations on dataframes - all you need
 is to inspect the schema a bit before making other decisions, such as which columns to
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)