Repository: narwhals-dev/narwhals
Pull request #2316 (2025-03-29 21:51:37)

fix: change rank ExprKind to transform
`rank` shouldn't have been classed as "window function"

it doesn't depend on the physical order of the data

in fact, it's forbidden to call `rank` with `over(order_by=...)`

<!--
# 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
- [ ] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues

- Related issue #\<issue number\>
- Closes #\<issue number\>

## Checklist

- [ ] Code follows style guide (ruff)
- [ ] Tests added
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below



Patch:
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index 4a883f10..4666e485 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -3,6 +3,7 @@ from __future__ import annotations
 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 not_implemented
 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 @@ if TYPE_CHECKING:
     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 @@ class ArrowDataFrame(EagerDataFrame["ArrowSeries", "ArrowExpr", "pa.Table"]):
         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 3623803d..a244ef4c 100644
--- a/narwhals/_compliant/dataframe.py
+++ b/narwhals/_compliant/dataframe.py
@@ -18,6 +18,7 @@ from narwhals._compliant.typing import EagerExprT_contra
 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 @@ if TYPE_CHECKING:
     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 @@ _ToDict: TypeAlias = "dict[str, CompliantSeriesT] | dict[str, list[Any]]"  # noq
 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/_duckdb/expr.py b/narwhals/_duckdb/expr.py
index b893cd9a..8be90212 100644
--- a/narwhals/_duckdb/expr.py
+++ b/narwhals/_duckdb/expr.py
@@ -713,8 +713,8 @@ class DuckDBExpr(LazyExpr["DuckDBLazyFrame", "duckdb.Expression"]):
                 by_sql = f"{_input} asc nulls last"
             order_by_sql = f"order by {by_sql}"
             sql = (
-                f"CASE WHEN {_input} IS NULL THEN NULL "
-                f"ELSE {func_name}() OVER ({order_by_sql}) END"
+                f"CASE WHEN {_input} IS NOT NULL THEN {func_name}() "
+                f"OVER ({order_by_sql}) END"
             )
             return SQLExpression(sql)
 
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index a0cd9b1d..0207bc97 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -32,6 +32,7 @@ from narwhals.dependencies import is_numpy_array_1d
 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 @@ if TYPE_CHECKING:
     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 @@ class PandasLikeDataFrame(EagerDataFrame["PandasLikeSeries", "PandasLikeExpr", "
         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 ed8458ea..ace46d09 100644
--- a/narwhals/_polars/dataframe.py
+++ b/narwhals/_polars/dataframe.py
@@ -6,6 +6,7 @@ from typing import Iterator
 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 extract_args_kwargs
 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 @@ if TYPE_CHECKING:
     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 @@ class PolarsDataFrame:
         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 3c74f74a..39a382f5 100644
--- a/narwhals/_translate.py
+++ b/narwhals/_translate.py
@@ -7,7 +7,9 @@ from typing import Mapping
 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 @@ else:  # pragma: no cover
             )
 
 
+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/expr.py b/narwhals/expr.py
index d1854957..1e15fe49 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -2543,11 +2543,10 @@ class Expr:
             )
             raise ValueError(msg)
 
-        return self.__class__(
+        return self._with_callable(
             lambda plx: self._to_compliant_expr(plx).rank(
                 method=method, descending=descending
-            ),
-            self._metadata.with_kind_and_extra_open_window(ExprKind.WINDOW),
+            )
         )
 
     @property
diff --git a/narwhals/functions.py b/narwhals/functions.py
index 0c051b1f..56457d3f 100644
--- a/narwhals/functions.py
+++ b/narwhals/functions.py
@@ -8,7 +8,6 @@ from typing import Any
 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._expression_parsing import is_scalar_like
 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_compliant_expr
 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 @@ if TYPE_CHECKING:
 
     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 @@ if TYPE_CHECKING:
 
     _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 6b2d5825..deb33e64 100644
--- a/narwhals/stable/v1/__init__.py
+++ b/narwhals/stable/v1/__init__.py
@@ -90,8 +90,8 @@ if TYPE_CHECKING:
     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 12d965fd..be697262 100644
--- a/narwhals/utils.py
+++ b/narwhals/utils.py
@@ -6,6 +6,7 @@ from datetime import timezone
 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 @@ if TYPE_CHECKING:
     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 @@ if TYPE_CHECKING:
     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 @@ if TYPE_CHECKING:
         - `_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:


Test patch:
diff --git a/tests/expression_parsing_test.py b/tests/expression_parsing_test.py
index 89d825ed..0f7e3067 100644
--- a/tests/expression_parsing_test.py
+++ b/tests/expression_parsing_test.py
@@ -27,3 +27,5 @@ def test_has_open_windows(expr: nw.Expr, expected: int) -> None:
 def test_misleading_order_by() -> None:
     with pytest.raises(InvalidOperationError):
         nw.col("a").mean().over(order_by="b")
+    with pytest.raises(InvalidOperationError):
+        nw.col("a").rank().over(order_by="b")
diff --git a/tests/from_pycapsule_test.py b/tests/from_pycapsule_test.py
index e9489b3d..ecbce908 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 @@ from tests.utils import assert_equal_data
 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)