Repository: narwhals-dev/narwhals
Pull request #2306: refactor: Add `CompliantDataFrame.from_arrow`
Author: login: dangotbanned
Labels: name: internal
name: pyarrow
description: Issue is related to pyarrow backend
Created: 2025-03-28T12:51:59Z | Merged: 2025-03-29T22:29:54Z

## What type of PR is this? (check all applicable)

- [x] 💾 Refactor
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [x] ✅ Test
- [x] 🐳 Other

## Related issues
- Part of #2116

## 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
- [x] Refine the error handling for each backend
  - Most of it can live in `ArrowDataFrame.from_arrow`
- [x] Widen to accept `pa.Table`
  - since we support `pyarrow>=11`, I think this function can still provide some value for those earlier versions
  - The `Arrow*` impl has similar logic to `pa.table` in `pyarrow==13.0.0`


Diff:
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index 4a883f108c..4666e485ec 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -3,6 +3,7 @@
 from functools import partial
 from typing import TYPE_CHECKING
 from typing import Any
+from typing import Collection
 from typing import Iterator
 from typing import Literal
 from typing import Mapping
@@ -32,6 +33,7 @@
 from narwhals.utils import parse_columns_to_drop
 from narwhals.utils import parse_version
 from narwhals.utils import scale_bytes
+from narwhals.utils import supports_arrow_c_stream
 from narwhals.utils import validate_backend_version
 
 if TYPE_CHECKING:
@@ -52,6 +54,7 @@
     from narwhals._arrow.typing import Indices  # type: ignore[attr-defined]
     from narwhals._arrow.typing import Mask  # type: ignore[attr-defined]
     from narwhals._arrow.typing import Order  # type: ignore[attr-defined]
+    from narwhals._translate import IntoArrowTable
     from narwhals.dtypes import DType
     from narwhals.schema import Schema
     from narwhals.typing import CompliantDataFrame
@@ -93,6 +96,26 @@ def __init__(
         self._version = version
         validate_backend_version(self._implementation, self._backend_version)
 
+    @classmethod
+    def from_arrow(cls, data: IntoArrowTable, /, *, context: _FullContext) -> Self:
+        backend_version = context._backend_version
+        if isinstance(data, pa.Table):
+            native = data
+        elif backend_version >= (14,) or isinstance(data, Collection):
+            native = pa.table(data)
+        elif supports_arrow_c_stream(data):  # pragma: no cover
+            msg = f"PyArrow>=14.0.0 is required for `from_arrow` for object of type {type(data).__name__!r}."
+            raise ModuleNotFoundError(msg)
+        else:  # pragma: no cover
+            msg = f"`from_arrow` is not supported for object of type {type(data).__name__!r}."
+            raise TypeError(msg)
+        return cls(
+            native,
+            backend_version=backend_version,
+            version=context._version,
+            validate_column_names=True,
+        )
+
     @classmethod
     def from_dict(
         cls,
diff --git a/narwhals/_compliant/dataframe.py b/narwhals/_compliant/dataframe.py
index 3623803da3..a244ef4cd3 100644
--- a/narwhals/_compliant/dataframe.py
+++ b/narwhals/_compliant/dataframe.py
@@ -18,6 +18,7 @@
 from narwhals._compliant.typing import EagerSeriesT
 from narwhals._compliant.typing import NativeFrameT_co
 from narwhals._expression_parsing import evaluate_output_names_and_aliases
+from narwhals._translate import ArrowConvertible
 from narwhals._translate import DictConvertible
 from narwhals._translate import NumpyConvertible
 from narwhals.utils import Version
@@ -35,6 +36,7 @@
     from typing_extensions import TypeAlias
 
     from narwhals._compliant.group_by import CompliantGroupBy
+    from narwhals._translate import IntoArrowTable
     from narwhals.dtypes import DType
     from narwhals.schema import Schema
     from narwhals.typing import SizeUnit
@@ -54,6 +56,7 @@
 class CompliantDataFrame(
     NumpyConvertible["_2DArray", "_2DArray"],
     DictConvertible["_ToDict[CompliantSeriesT]", Mapping[str, Any]],
+    ArrowConvertible["pa.Table", "IntoArrowTable"],
     _StoresNative[NativeFrameT_co],
     Sized,
     Protocol[CompliantSeriesT, CompliantExprT_contra, NativeFrameT_co],
@@ -66,6 +69,8 @@ class CompliantDataFrame(
     def __narwhals_dataframe__(self) -> Self: ...
     def __narwhals_namespace__(self) -> Any: ...
     @classmethod
+    def from_arrow(cls, data: IntoArrowTable, /, *, context: _FullContext) -> Self: ...
+    @classmethod
     def from_dict(
         cls,
         data: Mapping[str, Any],
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index a0cd9b1dc2..0207bc97a1 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -32,6 +32,7 @@
 from narwhals.exceptions import InvalidOperationError
 from narwhals.exceptions import ShapeError
 from narwhals.utils import Implementation
+from narwhals.utils import _into_arrow_table
 from narwhals.utils import _remap_full_join_keys
 from narwhals.utils import check_column_exists
 from narwhals.utils import generate_temporary_column_name
@@ -55,6 +56,7 @@
     from narwhals._pandas_like.expr import PandasLikeExpr
     from narwhals._pandas_like.group_by import PandasLikeGroupBy
     from narwhals._pandas_like.namespace import PandasLikeNamespace
+    from narwhals._translate import IntoArrowTable
     from narwhals.dtypes import DType
     from narwhals.schema import Schema
     from narwhals.typing import CompliantDataFrame
@@ -114,6 +116,29 @@ def __init__(
         if validate_column_names:
             check_column_names_are_unique(native_dataframe.columns)
 
+    @classmethod
+    def from_arrow(cls, data: IntoArrowTable, /, *, context: _FullContext) -> Self:
+        implementation = context._implementation
+        tbl = _into_arrow_table(data, context)
+        if implementation.is_pandas():
+            native = tbl.to_pandas()
+        elif implementation.is_modin():  # pragma: no cover
+            from modin.pandas.utils import from_arrow as mpd_from_arrow
+
+            native = mpd_from_arrow(tbl)
+        elif implementation.is_cudf():  # pragma: no cover
+            native = implementation.to_native_namespace().DataFrame.from_arrow(tbl)
+        else:  # pragma: no cover
+            msg = "congratulations, you entered unreachable code - please report a bug"
+            raise AssertionError(msg)
+        return cls(
+            native,
+            implementation=implementation,
+            backend_version=context._backend_version,
+            version=context._version,
+            validate_column_names=True,
+        )
+
     @classmethod
     def from_dict(
         cls,
diff --git a/narwhals/_polars/dataframe.py b/narwhals/_polars/dataframe.py
index ed8458ea2c..ace46d0981 100644
--- a/narwhals/_polars/dataframe.py
+++ b/narwhals/_polars/dataframe.py
@@ -6,6 +6,7 @@
 from typing import Literal
 from typing import Mapping
 from typing import Sequence
+from typing import cast
 from typing import overload
 
 import polars as pl
@@ -17,6 +18,7 @@
 from narwhals._polars.utils import native_to_narwhals_dtype
 from narwhals.exceptions import ColumnNotFoundError
 from narwhals.utils import Implementation
+from narwhals.utils import _into_arrow_table
 from narwhals.utils import is_sequence_but_not_str
 from narwhals.utils import parse_columns_to_drop
 from narwhals.utils import parse_version
@@ -35,6 +37,7 @@
     from narwhals._polars.group_by import PolarsGroupBy
     from narwhals._polars.group_by import PolarsLazyGroupBy
     from narwhals._polars.series import PolarsSeries
+    from narwhals._translate import IntoArrowTable
     from narwhals.dtypes import DType
     from narwhals.schema import Schema
     from narwhals.typing import CompliantDataFrame
@@ -94,6 +97,15 @@ def __init__(
         self._version = version
         validate_backend_version(self._implementation, self._backend_version)
 
+    @classmethod
+    def from_arrow(cls, data: IntoArrowTable, /, *, context: _FullContext) -> Self:
+        backend_version = context._backend_version
+        if backend_version >= (1, 3):
+            native = pl.DataFrame(data)
+        else:
+            native = cast("pl.DataFrame", pl.from_arrow(_into_arrow_table(data, context)))
+        return cls(native, backend_version=backend_version, version=context._version)
+
     @classmethod
     def from_dict(
         cls,
diff --git a/narwhals/_translate.py b/narwhals/_translate.py
index 3c74f74aff..39a382f55a 100644
--- a/narwhals/_translate.py
+++ b/narwhals/_translate.py
@@ -7,7 +7,9 @@
 from typing import Protocol
 
 if TYPE_CHECKING:
+    import pyarrow as pa
     from typing_extensions import Self
+    from typing_extensions import TypeAlias
     from typing_extensions import TypeVar
 
 
@@ -39,6 +41,10 @@ def TypeVar(  # noqa: ANN202, N802
             )
 
 
+class ArrowStreamExportable(Protocol):
+    def __arrow_c_stream__(self, requested_schema: object | None = None) -> object: ...
+
+
 ToNumpyT_co = TypeVar("ToNumpyT_co", covariant=True)
 FromNumpyDT_contra = TypeVar(
     "FromNumpyDT_contra", contravariant=True, default=ToNumpyT_co
@@ -98,3 +104,31 @@ class DictConvertible(
     FromDict[FromDictDT_contra],
     Protocol[ToDictDT_co, FromDictDT_contra],
 ): ...
+
+
+IntoArrowTable: TypeAlias = "ArrowStreamExportable | pa.Table"
+"""An object supporting the [Arrow PyCapsule Interface], or a native [`pyarrow.Table`].
+
+[Arrow PyCapsule Interface]: https://arrow.apache.org/docs/format/CDataInterface/PyCapsuleInterface.html#arrowstream-export
+[`pyarrow.Table`]: https://arrow.apache.org/docs/python/generated/pyarrow.Table.html
+"""
+ToArrowT_co = TypeVar("ToArrowT_co", covariant=True)
+FromArrowDT_contra = TypeVar(
+    "FromArrowDT_contra", contravariant=True, default=IntoArrowTable
+)
+
+
+class ToArrow(Protocol[ToArrowT_co]):
+    def to_arrow(self, *args: Any, **kwds: Any) -> ToArrowT_co: ...
+
+
+class FromArrow(Protocol[FromArrowDT_contra]):
+    @classmethod
+    def from_arrow(cls, data: FromArrowDT_contra, *args: Any, **kwds: Any) -> Self: ...
+
+
+class ArrowConvertible(
+    ToArrow[ToArrowT_co],
+    FromArrow[FromArrowDT_contra],
+    Protocol[ToArrowT_co, FromArrowDT_contra],
+): ...
diff --git a/narwhals/functions.py b/narwhals/functions.py
index 0c051b1fef..56457d3f31 100644
--- a/narwhals/functions.py
+++ b/narwhals/functions.py
@@ -8,7 +8,6 @@
 from typing import Iterable
 from typing import Literal
 from typing import Mapping
-from typing import Protocol
 from typing import Sequence
 from typing import cast
 from typing import overload
@@ -26,6 +25,7 @@
 from narwhals.dependencies import is_narwhals_series
 from narwhals.dependencies import is_numpy_array
 from narwhals.dependencies import is_numpy_array_2d
+from narwhals.dependencies import is_pyarrow_table
 from narwhals.expr import Expr
 from narwhals.series import Series
 from narwhals.translate import from_native
@@ -39,6 +39,7 @@
 from narwhals.utils import is_eager_allowed
 from narwhals.utils import is_sequence_but_not_str
 from narwhals.utils import parse_version
+from narwhals.utils import supports_arrow_c_stream
 from narwhals.utils import validate_laziness
 
 if TYPE_CHECKING:
@@ -50,6 +51,7 @@
 
     from narwhals._compliant import CompliantExpr
     from narwhals._compliant import CompliantNamespace
+    from narwhals._translate import IntoArrowTable
     from narwhals.dataframe import DataFrame
     from narwhals.dataframe import LazyFrame
     from narwhals.dtypes import DType
@@ -65,11 +67,6 @@
 
     _IntoSchema: TypeAlias = "Mapping[str, DType] | Schema | Sequence[str] | None"
 
-    class ArrowStreamExportable(Protocol):
-        def __arrow_c_stream__(
-            self, requested_schema: object | None = None
-        ) -> object: ...
-
 
 @overload
 def concat(
@@ -486,7 +483,7 @@ def _is_into_schema(obj: Any) -> TypeIs[_IntoSchema]:
 
 @deprecate_native_namespace(warn_version="1.31.0", required=True)
 def from_arrow(
-    native_frame: ArrowStreamExportable,
+    native_frame: IntoArrowTable,
     *,
     backend: ModuleType | Implementation | str | None = None,
     native_namespace: ModuleType | None = None,  # noqa: ARG001
@@ -535,55 +532,25 @@ def from_arrow(
         └──────────────────┘
     """
     backend = cast("ModuleType | Implementation | str", backend)
-    return _from_arrow_impl(native_frame, backend=backend)
+    return _from_arrow_impl(native_frame, backend=backend, version=Version.MAIN)
 
 
 def _from_arrow_impl(
-    data: ArrowStreamExportable, *, backend: ModuleType | Implementation | str
+    data: IntoArrowTable,
+    *,
+    backend: ModuleType | Implementation | str,
+    version: Version,
 ) -> DataFrame[Any]:
-    if not hasattr(data, "__arrow_c_stream__"):
+    if not (supports_arrow_c_stream(data) or is_pyarrow_table(data)):
         msg = f"Given object of type {type(data)} does not support PyCapsule interface"
         raise TypeError(msg)
-
     implementation = Implementation.from_backend(backend)
-    native_namespace = implementation.to_native_namespace()
-
-    if implementation.is_polars() and parse_version(native_namespace) >= (1, 3):
-        native_frame = native_namespace.DataFrame(data)
-    elif implementation in {
-        Implementation.PANDAS,
-        Implementation.MODIN,
-        Implementation.CUDF,
-        Implementation.POLARS,
-    }:
-        # These don't (yet?) support the PyCapsule Interface for import
-        # so we go via PyArrow
-        try:
-            import pyarrow as pa  # ignore-banned-import
-        except ModuleNotFoundError as exc:  # pragma: no cover
-            msg = f"PyArrow>=14.0.0 is required for `from_arrow` for object of type {native_namespace}"
-            raise ModuleNotFoundError(msg) from exc
-        if parse_version(pa) < (14, 0):  # pragma: no cover
-            msg = f"PyArrow>=14.0.0 is required for `from_arrow` for object of type {native_namespace}"
-            raise ModuleNotFoundError(msg) from None
-
-        tbl = pa.table(data)
-        if implementation is Implementation.PANDAS:
-            native_frame = tbl.to_pandas()
-        elif implementation is Implementation.MODIN:  # pragma: no cover
-            from modin.pandas.utils import from_arrow
-
-            native_frame = from_arrow(tbl)
-        elif implementation is Implementation.CUDF:  # pragma: no cover
-            native_frame = native_namespace.DataFrame.from_arrow(tbl)
-        elif implementation is Implementation.POLARS:  # pragma: no cover
-            native_frame = native_namespace.from_arrow(tbl)
-        else:  # pragma: no cover
-            msg = "congratulations, you entered unrecheable code - please report a bug"
-            raise AssertionError(msg)
-    elif implementation is Implementation.PYARROW:
-        native_frame = native_namespace.table(data)
+    if is_eager_allowed(implementation):
+        ns = _into_compliant_namespace(implementation, version)
+        frame = ns._dataframe.from_arrow(data, context=ns)
+        return from_native(frame, eager_only=True)
     else:  # pragma: no cover
+        native_namespace = implementation.to_native_namespace()
         try:
             # implementation is UNKNOWN, Narwhals extension using this feature should
             # implement PyCapsule support
@@ -591,7 +558,7 @@ def _from_arrow_impl(
         except AttributeError as e:
             msg = "Unknown namespace is expected to implement `DataFrame` class which accepts object which supports PyCapsule Interface."
             raise AttributeError(msg) from e
-    return from_native(native_frame, eager_only=True)
+        return from_native(native_frame, eager_only=True)
 
 
 def _get_sys_info() -> dict[str, str]:
diff --git a/narwhals/stable/v1/__init__.py b/narwhals/stable/v1/__init__.py
index 6b2d5825bb..deb33e6481 100644
--- a/narwhals/stable/v1/__init__.py
+++ b/narwhals/stable/v1/__init__.py
@@ -90,8 +90,8 @@
     from typing_extensions import Self
     from typing_extensions import TypeVar
 
+    from narwhals._translate import IntoArrowTable
     from narwhals.dtypes import DType
-    from narwhals.functions import ArrowStreamExportable
     from narwhals.typing import IntoExpr
     from narwhals.typing import IntoFrame
     from narwhals.typing import IntoLazyFrameT
@@ -2213,7 +2213,7 @@ def new_series(
 
 @deprecate_native_namespace(required=True)
 def from_arrow(
-    native_frame: ArrowStreamExportable,
+    native_frame: IntoArrowTable,
     *,
     backend: ModuleType | Implementation | str | None = None,
     native_namespace: ModuleType | None = None,  # noqa: ARG001
@@ -2242,7 +2242,7 @@ def from_arrow(
     """
     backend = cast("ModuleType | Implementation | str", backend)
     return _stableify(  # type: ignore[no-any-return]
-        _from_arrow_impl(native_frame, backend=backend)
+        _from_arrow_impl(native_frame, backend=backend, version=Version.V1)
     )
 
 
diff --git a/narwhals/utils.py b/narwhals/utils.py
index 12d965fd6a..be69726204 100644
--- a/narwhals/utils.py
+++ b/narwhals/utils.py
@@ -6,6 +6,7 @@
 from enum import Enum
 from enum import auto
 from functools import wraps
+from importlib.util import find_spec
 from inspect import getattr_static
 from secrets import token_hex
 from typing import TYPE_CHECKING
@@ -49,6 +50,7 @@
     from typing import AbstractSet as Set
 
     import pandas as pd
+    import pyarrow as pa
     from typing_extensions import LiteralString
     from typing_extensions import ParamSpec
     from typing_extensions import Self
@@ -69,10 +71,11 @@
     from narwhals._pandas_like.namespace import PandasLikeNamespace
     from narwhals._polars.namespace import PolarsNamespace
     from narwhals._spark_like.namespace import SparkLikeNamespace
+    from narwhals._translate import ArrowStreamExportable
+    from narwhals._translate import IntoArrowTable
     from narwhals.dataframe import DataFrame
     from narwhals.dataframe import LazyFrame
     from narwhals.dtypes import DType
-    from narwhals.functions import ArrowStreamExportable
     from narwhals.series import Series
     from narwhals.typing import CompliantDataFrame
     from narwhals.typing import CompliantLazyFrame
@@ -133,7 +136,7 @@ class _LimitedContext(_StoresBackendVersion, _StoresVersion, Protocol):
         - `_version`
         """
 
-    class _FullContext(_StoresImplementation, _LimitedContext, Protocol):  # noqa: PYI046
+    class _FullContext(_StoresImplementation, _LimitedContext, Protocol):
         """Provides 3 attributes.
 
         - `_implementation`
@@ -1593,6 +1596,29 @@ def _remap_full_join_keys(
     return dict(zip(right_on, right_keys_suffixed))
 
 
+def _into_arrow_table(data: IntoArrowTable, context: _FullContext, /) -> pa.Table:
+    """Guards `ArrowDataFrame.from_arrow` w/ safer imports.
+
+    Arguments:
+        data: Object which implements `__arrow_c_stream__`.
+        context: Initialized compliant object.
+
+    Returns:
+        A PyArrow Table.
+    """
+    if find_spec("pyarrow"):
+        import pyarrow as pa  # ignore-banned-import
+
+        from narwhals._arrow.namespace import ArrowNamespace
+
+        version = context._version
+        ns = ArrowNamespace(backend_version=parse_version(pa), version=version)
+        return ns._dataframe.from_arrow(data, context=ns).native
+    else:  # pragma: no cover
+        msg = f"PyArrow>=14.0.0 is required for `from_arrow` for object of type {type(data).__name__!r}."
+        raise ModuleNotFoundError(msg)
+
+
 # TODO @dangotbanned: Extend with runtime behavior for `v1.*`
 # See `narwhals.exceptions.NarwhalsUnstableWarning`
 def unstable(fn: _Fn, /) -> _Fn:
diff --git a/tests/from_pycapsule_test.py b/tests/from_pycapsule_test.py
index e9489b3d3f..ecbce908e9 100644
--- a/tests/from_pycapsule_test.py
+++ b/tests/from_pycapsule_test.py
@@ -1,6 +1,8 @@
 from __future__ import annotations
 
 import sys
+from dataclasses import dataclass
+from typing import Any
 
 import pandas as pd
 import pytest
@@ -12,6 +14,16 @@
 pytest.importorskip("pyarrow")
 import pyarrow as pa
 
+from narwhals.utils import Implementation
+from narwhals.utils import Version
+
+
+@dataclass
+class FullContext:
+    _implementation: Implementation
+    _backend_version: tuple[int, ...]
+    _version: Version
+
 
 @pytest.mark.xfail(PYARROW_VERSION < (14,), reason="too old")
 def test_from_arrow_to_arrow() -> None:
@@ -25,15 +37,17 @@ def test_from_arrow_to_arrow() -> None:
     assert_equal_data(result, expected)
 
 
-@pytest.mark.xfail(PYARROW_VERSION < (14,), reason="too old")
 def test_from_arrow_to_polars(monkeypatch: pytest.MonkeyPatch) -> None:
     pytest.importorskip("polars")
     import polars as pl
 
     tbl = pa.table({"ab": [1, 2, 3], "ba": [4, 5, 6]})
     monkeypatch.delitem(sys.modules, "pandas")
-    df = nw.from_native(tbl, eager_only=True)
-    result = nw.from_arrow(df, backend=pl)
+    if PYARROW_VERSION < (14,):  # pragma: no cover
+        result = nw.from_arrow(tbl, backend=pl)
+    else:
+        df = nw.from_native(tbl, eager_only=True)
+        result = nw.from_arrow(df, backend=pl)
     assert isinstance(result.to_native(), pl.DataFrame)
     expected = {"ab": [1, 2, 3], "ba": [4, 5, 6]}
     assert_equal_data(result, expected)
@@ -52,3 +66,17 @@ def test_from_arrow_to_pandas() -> None:
 def test_from_arrow_invalid() -> None:
     with pytest.raises(TypeError, match="PyCapsule"):
         nw.from_arrow({"a": [1]}, backend=pa)  # type: ignore[arg-type]
+
+
+@pytest.mark.parametrize(
+    "context", [FullContext(Implementation.POLARS, (1, 0, 0), Version.V1)]
+)
+def test_from_arrow_pre_14(context: FullContext) -> None:
+    pytest.importorskip("polars")
+    from narwhals._polars.dataframe import PolarsDataFrame
+
+    expected: dict[str, Any] = {"ab": [1, 2, 3], "ba": [4, 5, 6]}
+    tbl = pa.table(expected)
+    compliant = PolarsDataFrame.from_arrow(tbl, context=context)
+    result = nw.from_native(compliant.to_polars(), eager_only=True)
+    assert_equal_data(result, expected)


Reviews:
user: login: dangotbanned
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T18:09:08Z
user: login: dangotbanned
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T18:09:58Z
user: login: dangotbanned
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T18:13:13Z
user: login: dangotbanned
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T18:33:47Z
user: login: dangotbanned
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T18:43:27Z
user: login: MarcoGorelli
body: nice, thanks @dangotbanned !

![](https://i.fluffy.cc/g6TR0RVJMJ4f5nWrDR39tNrddc3gNSdm.gif)
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-29T22:00:12Z
user: login: MarcoGorelli
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-29T22:02:45Z
user: login: dangotbanned
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-29T22:27:50Z

Review comments:
diff_hunk: @@ -25,15 +37,17 @@ def test_from_arrow_to_arrow() -> None:
     assert_equal_data(result, expected)
 
 
-@pytest.mark.xfail(PYARROW_VERSION < (14,), reason="too old")
 def test_from_arrow_to_polars(monkeypatch: pytest.MonkeyPatch) -> None:
     pytest.importorskip("polars")
     import polars as pl
 
     tbl = pa.table({"ab": [1, 2, 3], "ba": [4, 5, 6]})
     monkeypatch.delitem(sys.modules, "pandas")
-    df = nw.from_native(tbl, eager_only=True)
-    result = nw.from_arrow(df, backend=pl)
+    if PYARROW_VERSION < (14,):
path: tests/from_pycapsule_test.py
user: login: dangotbanned
body: https://github.com/narwhals-dev/narwhals/actions/runs/14134927029/job/39604239875?pr=2306

Feel like this would be good to have coverage for, but not sure how feasible that is with the current CI.
Adding a `# pragma: no cover` seems wrong
created_at: 2025-03-28T18:09:08Z
author_association: MEMBER
diff_hunk: @@ -25,15 +37,17 @@ def test_from_arrow_to_arrow() -> None:
     assert_equal_data(result, expected)
 
 
-@pytest.mark.xfail(PYARROW_VERSION < (14,), reason="too old")
 def test_from_arrow_to_polars(monkeypatch: pytest.MonkeyPatch) -> None:
     pytest.importorskip("polars")
     import polars as pl
 
     tbl = pa.table({"ab": [1, 2, 3], "ba": [4, 5, 6]})
     monkeypatch.delitem(sys.modules, "pandas")
-    df = nw.from_native(tbl, eager_only=True)
-    result = nw.from_arrow(df, backend=pl)
+    if PYARROW_VERSION < (14,):
path: tests/from_pycapsule_test.py
user: login: dangotbanned
body: Also, it means the previous `xfail`(s) were never triggered? 
created_at: 2025-03-28T18:09:58Z
author_association: MEMBER
diff_hunk: @@ -25,15 +37,17 @@ def test_from_arrow_to_arrow() -> None:
     assert_equal_data(result, expected)
 
 
-@pytest.mark.xfail(PYARROW_VERSION < (14,), reason="too old")
 def test_from_arrow_to_polars(monkeypatch: pytest.MonkeyPatch) -> None:
     pytest.importorskip("polars")
     import polars as pl
 
     tbl = pa.table({"ab": [1, 2, 3], "ba": [4, 5, 6]})
     monkeypatch.delitem(sys.modules, "pandas")
-    df = nw.from_native(tbl, eager_only=True)
-    result = nw.from_arrow(df, backend=pl)
+    if PYARROW_VERSION < (14,):
path: tests/from_pycapsule_test.py
user: login: dangotbanned
body: @MarcoGorelli I was planning to adapt the rest of the tests in a similar way following
- https://github.com/narwhals-dev/narwhals/pull/2306/commits/adb6bf107082bcd49e1e0a64c36e5431470940f8

Do you have a preference on what to do here?
created_at: 2025-03-28T18:13:13Z
author_association: MEMBER
diff_hunk: @@ -25,15 +37,17 @@ def test_from_arrow_to_arrow() -> None:
     assert_equal_data(result, expected)
 
 
-@pytest.mark.xfail(PYARROW_VERSION < (14,), reason="too old")
 def test_from_arrow_to_polars(monkeypatch: pytest.MonkeyPatch) -> None:
     pytest.importorskip("polars")
     import polars as pl
 
     tbl = pa.table({"ab": [1, 2, 3], "ba": [4, 5, 6]})
     monkeypatch.delitem(sys.modules, "pandas")
-    df = nw.from_native(tbl, eager_only=True)
-    result = nw.from_arrow(df, backend=pl)
+    if PYARROW_VERSION < (14,):
path: tests/from_pycapsule_test.py
user: login: dangotbanned
body: Added a no cover for now in (https://github.com/narwhals-dev/narwhals/pull/2306/commits/7bf8b277b3409c28ca8474a46649905cc3955392)
created_at: 2025-03-28T18:33:47Z
author_association: MEMBER
diff_hunk: @@ -93,6 +96,26 @@ def __init__(
         self._version = version
         validate_backend_version(self._implementation, self._backend_version)
 
+    @classmethod
+    def from_arrow(cls, data: IntoArrowTable, /, *, context: _FullContext) -> Self:
+        backend_version = context._backend_version
+        if isinstance(data, pa.Table):
+            native = data
+        elif backend_version >= (14,) or isinstance(data, Collection):
path: narwhals/_arrow/dataframe.py
user: login: dangotbanned
body: Supporting `Collection` here might seem strange.

My thinking is that *were* a `list | tuple | dict` of `pyarrow` objects passed - `pa.table` would still be capable of turning that into a `pa.Table`:
https://github.com/apache/arrow/blob/7c1b96e26910dd553ee0ccf0792e1854af6b0021/python/pyarrow/table.pxi#L4993-L5000
created_at: 2025-03-28T18:43:26Z
author_association: MEMBER
diff_hunk: @@ -114,6 +116,29 @@ def __init__(
         if validate_column_names:
             check_column_names_are_unique(native_dataframe.columns)
 
+    @classmethod
+    def from_arrow(cls, data: IntoArrowTable, /, *, context: _FullContext) -> Self:
+        implementation = context._implementation
+        tbl = _into_arrow_table(data, context)
+        if implementation.is_pandas():
+            native = tbl.to_pandas()
+        elif implementation.is_modin():  # pragma: no cover
+            from modin.pandas.utils import from_arrow as mpd_from_arrow
+
+            native = mpd_from_arrow(tbl)
+        elif implementation.is_cudf():  # pragma: no cover
+            native = implementation.to_native_namespace().DataFrame.from_arrow(tbl)
+        else:  # pragma: no cover
+            msg = "congratulations, you entered unreachable code - please report a bug"
+            raise AssertionError(msg)
path: narwhals/_pandas_like/dataframe.py
user: login: MarcoGorelli
body: lol, can't believe i wrote this, we may want to make a utility with a consistent message for these at some point
created_at: 2025-03-29T21:59:17Z
author_association: MEMBER
diff_hunk: @@ -25,15 +37,17 @@ def test_from_arrow_to_arrow() -> None:
     assert_equal_data(result, expected)
 
 
-@pytest.mark.xfail(PYARROW_VERSION < (14,), reason="too old")
 def test_from_arrow_to_polars(monkeypatch: pytest.MonkeyPatch) -> None:
     pytest.importorskip("polars")
     import polars as pl
 
     tbl = pa.table({"ab": [1, 2, 3], "ba": [4, 5, 6]})
     monkeypatch.delitem(sys.modules, "pandas")
-    df = nw.from_native(tbl, eager_only=True)
-    result = nw.from_arrow(df, backend=pl)
+    if PYARROW_VERSION < (14,):
path: tests/from_pycapsule_test.py
user: login: MarcoGorelli
body: no strong preference
created_at: 2025-03-29T22:02:44Z
author_association: MEMBER
diff_hunk: @@ -114,6 +116,29 @@ def __init__(
         if validate_column_names:
             check_column_names_are_unique(native_dataframe.columns)
 
+    @classmethod
+    def from_arrow(cls, data: IntoArrowTable, /, *, context: _FullContext) -> Self:
+        implementation = context._implementation
+        tbl = _into_arrow_table(data, context)
+        if implementation.is_pandas():
+            native = tbl.to_pandas()
+        elif implementation.is_modin():  # pragma: no cover
+            from modin.pandas.utils import from_arrow as mpd_from_arrow
+
+            native = mpd_from_arrow(tbl)
+        elif implementation.is_cudf():  # pragma: no cover
+            native = implementation.to_native_namespace().DataFrame.from_arrow(tbl)
+        else:  # pragma: no cover
+            msg = "congratulations, you entered unreachable code - please report a bug"
+            raise AssertionError(msg)
path: narwhals/_pandas_like/dataframe.py
user: login: dangotbanned
body: Certainly made me smile when I copied it over 😄 
created_at: 2025-03-29T22:27:50Z
author_association: MEMBER

PR comments:
user: login: dangotbanned
created_at: 2025-03-28T15:40:44Z
body: @MarcoGorelli I'm close to finishing, just wanted to check in and see if you had any thoughts on supporting `pa.Table` here?

I left some notes in the description on why it makes sense to me 🙂

Would be very easy to support across all versions
author_association: MEMBER
user: login: MarcoGorelli
created_at: 2025-03-28T16:15:15Z
body: thanks @dangotbanned ! sure, should be good to accept pa.table pre-pycapsule
author_association: MEMBER
user: login: dangotbanned
created_at: 2025-03-28T16:43:46Z
body: > sure, should be good to accept pa.table pre-pycapsule

Perfect, thanks @MarcoGorelli 

Still need to test how `pa.table` responds to `pa.Table` for post-pycapsule.
I *think* it would use `__arrow_c_stream__` but there's only one way to find out

### Update
Yeah, all good!

#### `pyarrow>=14`
```py
import pyarrow as pa

data = {"ab": [1, 2, 3], "ba": [4, 5, 6]}
df = pa.table(data)
>>> df
pyarrow.Table
ab: int64
ba: int64
----
ab: [[1,2,3]]
ba: [[4,5,6]]

repeat = pa.table(df)
>>> repeat
pyarrow.Table
ab: int64
ba: int64
----
ab: [[1,2,3]]
ba: [[4,5,6]]
```

So we can just document support for both - and what I have *already* adds support for `pa.Table` w/ `pyarrow<14`:

```py
IntoArrowTable: TypeAlias = ArrowStreamExportable | pa.Table
```
author_association: MEMBER