Repository: narwhals-dev/narwhals

Issue:
enh: add `to_py_scalar`
in the plotly PR () we have

```
def to_py_scalar(scalar_like):
    """If scalar is not python native, tries to convert it to python native."""
    if hasattr(scalar_like, "as_py"):
        return scalar_like.as_py()
    elif hasattr(scalar_like, "item"):
        return scalar_like.item()
    else:
        return scalar_like
```

I think we should have this in Narwhals itself (we _may_ want it to be slightly more robust by doing `isinstance` checks instead of checking for attributes)

Discussion:

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 89d05e54..4d416e23 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -40,7 +40,7 @@ repos:
   hooks:
     - id: nbstripout
 - repo: https://github.com/adamchainz/blacken-docs
-  rev: "1.18.0"  # replace with latest tag on GitHub
+  rev: "1.19.0"  # replace with latest tag on GitHub
   hooks:
   -   id: blacken-docs
       args: [--skip-errors]
diff --git a/docs/api-reference/narwhals.md b/docs/api-reference/narwhals.md
index b8ec2d79..c4b04a2f 100644
--- a/docs/api-reference/narwhals.md
+++ b/docs/api-reference/narwhals.md
@@ -39,4 +39,5 @@ Here are the top-level functions available in Narwhals.
         - when
         - show_versions
         - to_native
+        - to_py_scalar
       show_source: false
diff --git a/docs/installation.md b/docs/installation.md
index 974eaf1f..1695a7ee 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -2,20 +2,36 @@
 
 ## Installation
 
-First, make sure you have [created and activated](https://docs.python.org/3/library/venv.html) a Python3.8+ virtual environment.
+=== "UV"
 
-Then, run
-```console
-python -m pip install narwhals
-```
+    First, ensure you have installed [UV](https://github.com/astral-sh/uv), and make sure you have [created and activated](https://docs.astral.sh/uv/pip/environments/#python-environments) a Python 3.8+ virtual environment.
+
+    If you haven't, you can follow our [_setting up your environment_](https://github.com/narwhals-dev/narwhals/blob/main/CONTRIBUTING.md#option-1-use-uv-recommended) guide.
+    Then, run:
+
+    ```console
+    uv pip install narwhals
+    ```
+
+=== "Python's venv"
+
+    First, ensure you have [created and activated](https://docs.python.org/3/library/venv.html) a Python 3.8+ virtual environment.
+
+    Then, run:
+
+    ```console
+    python -m pip install narwhals
+    ```
+
+### Verifying the Installation
 
-Then, if you start the Python REPL and see the following:
+To verify the installation, start the Python REPL and execute:
 ```python
 >>> import narwhals
 >>> narwhals.__version__
 '1.9.4'
 ```
-then installation worked correctly!
+If you see the version number, then the installation was successful!
 
 ## Quick start
 
@@ -69,4 +85,4 @@ If you run `python t.py` then your output should look like the above. This is th
 function - as we'll soon see, we can do much more advanced things.
 Let's learn about what you just did, and what Narwhals can do for you!
 
-Note: these examples are only using pandas and Polars. Please see the following to find the [supported libraries](extending.md).
+Note: these examples are only using pandas, Polars and PyArrow. Please see the following to find the [supported libraries](extending.md).
diff --git a/mkdocs.yml b/mkdocs.yml
index 75e2b0fd..3793d898 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -26,6 +26,7 @@ nav:
     - Supported Expr methods: api-completeness/expr.md
     - Supported Series methods: api-completeness/series.md
   - API Reference:
+    - api-reference/index.md
     - api-reference/narwhals.md
     - api-reference/dataframe.md
     - api-reference/expr.md
@@ -42,7 +43,6 @@ nav:
     - api-reference/series_str.md
     - api-reference/dependencies.md
     - api-reference/dtypes.md
-    - api-reference/index.md
     - api-reference/selectors.md
     - api-reference/typing.md
 theme:
diff --git a/narwhals/__init__.py b/narwhals/__init__.py
index aeba3ef5..8dd76d08 100644
--- a/narwhals/__init__.py
+++ b/narwhals/__init__.py
@@ -58,6 +58,7 @@ from narwhals.translate import from_native
 from narwhals.translate import get_native_namespace
 from narwhals.translate import narwhalify
 from narwhals.translate import to_native
+from narwhals.translate import to_py_scalar
 from narwhals.utils import is_ordered_categorical
 from narwhals.utils import maybe_align_index
 from narwhals.utils import maybe_convert_dtypes
@@ -84,6 +85,7 @@ __all__ = [
     "maybe_reset_index",
     "maybe_set_index",
     "get_native_namespace",
+    "to_py_scalar",
     "all",
     "all_horizontal",
     "any_horizontal",
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index a0a4e16c..6b87f1d8 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -83,6 +83,26 @@ class ArrowDataFrame:
     def row(self, index: int) -> tuple[Any, ...]:
         return tuple(col[index] for col in self._native_frame)
 
+    @overload
+    def rows(
+        self,
+        *,
+        named: Literal[True],
+    ) -> list[dict[str, Any]]: ...
+
+    @overload
+    def rows(
+        self,
+        *,
+        named: Literal[False] = False,
+    ) -> list[tuple[Any, ...]]: ...
+    @overload
+    def rows(
+        self,
+        *,
+        named: bool,
+    ) -> list[tuple[Any, ...]] | list[dict[str, Any]]: ...
+
     def rows(
         self, *, named: bool = False
     ) -> list[tuple[Any, ...]] | list[dict[str, Any]]:
@@ -141,13 +161,15 @@ class ArrowDataFrame:
 
     def __getitem__(
         self,
-        item: str
-        | slice
-        | Sequence[int]
-        | Sequence[str]
-        | tuple[Sequence[int], str | int]
-        | tuple[slice, str | int]
-        | tuple[slice, slice],
+        item: (
+            str
+            | slice
+            | Sequence[int]
+            | Sequence[str]
+            | tuple[Sequence[int], str | int]
+            | tuple[slice, str | int]
+            | tuple[slice, slice]
+        ),
     ) -> ArrowSeries | ArrowDataFrame:
         if isinstance(item, tuple):
             item = tuple(list(i) if is_sequence_but_not_str(i) else i for i in item)
@@ -496,7 +518,9 @@ class ArrowDataFrame:
 
     def collect(self) -> ArrowDataFrame:
         return ArrowDataFrame(
-            self._native_frame, backend_version=self._backend_version, dtypes=self._dtypes
+            self._native_frame,
+            backend_version=self._backend_version,
+            dtypes=self._dtypes,
         )
 
     def clone(self) -> Self:
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 65a393ca..2eb73829 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -784,7 +784,7 @@ class ArrowSeriesDateTimeNamespace:
         import pyarrow as pa  # ignore-banned-import()
 
         return self._arrow_series._from_native_series(
-            self._arrow_series._native_series.cast(pa.date64())
+            self._arrow_series._native_series.cast(pa.date32())
         )
 
     def year(self: Self) -> ArrowSeries:
diff --git a/narwhals/_expression_parsing.py b/narwhals/_expression_parsing.py
index d281cc94..89d020b0 100644
--- a/narwhals/_expression_parsing.py
+++ b/narwhals/_expression_parsing.py
@@ -219,19 +219,21 @@ def reuse_series_implementation(
     plx = expr.__narwhals_namespace__()
 
     def func(df: CompliantDataFrame) -> list[CompliantSeries]:
-        out: list[CompliantSeries] = []
-        for column in expr._call(df):  # type: ignore[arg-type]
-            _out = getattr(column, attr)(
-                *[maybe_evaluate_expr(df, arg) for arg in args],
-                **{
-                    arg_name: maybe_evaluate_expr(df, arg_value)
-                    for arg_name, arg_value in kwargs.items()
-                },
+        _args = [maybe_evaluate_expr(df, arg) for arg in args]
+        _kwargs = {
+            arg_name: maybe_evaluate_expr(df, arg_value)
+            for arg_name, arg_value in kwargs.items()
+        }
+
+        out: list[CompliantSeries] = [
+            plx._create_series_from_scalar(
+                getattr(column, attr)(*_args, **_kwargs),
+                column,  # type: ignore[arg-type]
             )
-            if returns_scalar:
-                out.append(plx._create_series_from_scalar(_out, column))  # type: ignore[arg-type]
-            else:
-                out.append(_out)
+            if returns_scalar
+            else getattr(column, attr)(*_args, **_kwargs)
+            for column in expr._call(df)  # type: ignore[arg-type]
+        ]
         if expr._output_names is not None and (
             [s.name for s in out] != expr._output_names
         ):  # pragma: no cover
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index 6a629298..8380b48d 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -141,14 +141,16 @@ class PandasLikeDataFrame:
 
     def __getitem__(
         self,
-        item: str
-        | int
-        | slice
-        | Sequence[int]
-        | Sequence[str]
-        | tuple[Sequence[int], str | int]
-        | tuple[slice | Sequence[int], Sequence[int] | slice]
-        | tuple[slice, slice],
+        item: (
+            str
+            | int
+            | slice
+            | Sequence[int]
+            | Sequence[str]
+            | tuple[Sequence[int], str | int]
+            | tuple[slice | Sequence[int], Sequence[int] | slice]
+            | tuple[slice, slice]
+        ),
     ) -> PandasLikeSeries | PandasLikeDataFrame:
         if isinstance(item, tuple):
             item = tuple(list(i) if is_sequence_but_not_str(i) else i for i in item)
@@ -245,10 +247,36 @@ class PandasLikeDataFrame:
     def columns(self) -> list[str]:
         return self._native_frame.columns.tolist()  # type: ignore[no-any-return]
 
+    @overload
+    def rows(
+        self,
+        *,
+        named: Literal[True],
+    ) -> list[dict[str, Any]]: ...
+
+    @overload
+    def rows(
+        self,
+        *,
+        named: Literal[False] = False,
+    ) -> list[tuple[Any, ...]]: ...
+
+    @overload
+    def rows(
+        self,
+        *,
+        named: bool,
+    ) -> list[tuple[Any, ...]] | list[dict[str, Any]]: ...
+
     def rows(
         self, *, named: bool = False
     ) -> list[tuple[Any, ...]] | list[dict[str, Any]]:
         if not named:
+            # cuDF does not support itertuples. But it does support to_dict!
+            if self._implementation is Implementation.CUDF:  # pragma: no cover
+                # Extract the row values from the named rows
+                return [tuple(row.values()) for row in self.rows(named=True)]
+
             return list(self._native_frame.itertuples(index=False, name=None))
 
         return self._native_frame.to_dict(orient="records")  # type: ignore[no-any-return]
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index 5c8c7e13..4645cfbb 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -582,9 +582,9 @@ class DataFrame(BaseFrame[DataFrameT]):
 
             We can then pass either pandas, Polars or PyArrow to `func`:
 
-            >>> func(df_pd)
-            >>> func(df_pl)
-            >>> func(df_pa)
+            >>> func(df_pd)  # doctest:+SKIP
+            >>> func(df_pl)  # doctest:+SKIP
+            >>> func(df_pa)  # doctest:+SKIP
         """
         self._compliant_frame.write_parquet(file)
 
@@ -742,14 +742,16 @@ class DataFrame(BaseFrame[DataFrameT]):
 
     def __getitem__(
         self,
-        item: str
-        | slice
-        | Sequence[int]
-        | Sequence[str]
-        | tuple[Sequence[int], str | int]
-        | tuple[slice, str | int]
-        | tuple[slice | Sequence[int], Sequence[int] | Sequence[str] | slice]
-        | tuple[slice, slice],
+        item: (
+            str
+            | slice
+            | Sequence[int]
+            | Sequence[str]
+            | tuple[Sequence[int], str | int]
+            | tuple[slice, str | int]
+            | tuple[slice | Sequence[int], Sequence[int] | Sequence[str] | slice]
+            | tuple[slice, slice]
+        ),
     ) -> Series | Self:
         """
         Extract column or slice of DataFrame.
@@ -1195,16 +1197,14 @@ class DataFrame(BaseFrame[DataFrameT]):
     def rows(
         self,
         *,
-        named: Literal[False],
+        named: Literal[False] = False,
     ) -> list[tuple[Any, ...]]: ...
-
     @overload
     def rows(
         self,
         *,
         named: Literal[True],
     ) -> list[dict[str, Any]]: ...
-
     @overload
     def rows(
         self,
diff --git a/narwhals/dependencies.py b/narwhals/dependencies.py
index 144c57c8..1f9ae19f 100644
--- a/narwhals/dependencies.py
+++ b/narwhals/dependencies.py
@@ -46,6 +46,11 @@ def get_cudf() -> Any:
     return sys.modules.get("cudf", None)
 
 
+def get_cupy() -> Any:
+    """Get cupy module (if already imported - else return None)."""
+    return sys.modules.get("cupy", None)
+
+
 def get_pyarrow() -> Any:  # pragma: no cover
     """Get pyarrow module (if already imported - else return None)."""
     return sys.modules.get("pyarrow", None)
diff --git a/narwhals/expr.py b/narwhals/expr.py
index 6eedbafa..97c2e2d3 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -367,9 +367,11 @@ class Expr:
         Examples:
             >>> import polars as pl
             >>> import pandas as pd
+            >>> import pyarrow as pa
             >>> import narwhals as nw
             >>> df_pd = pd.DataFrame({"a": [-1, 0, 1], "b": [2, 4, 6]})
             >>> df_pl = pl.DataFrame({"a": [-1, 0, 1], "b": [2, 4, 6]})
+            >>> df_pa = pa.table({"a": [-1, 0, 1], "b": [2, 4, 6]})
 
             Let's define a dataframe-agnostic function:
 
@@ -377,7 +379,7 @@ class Expr:
             ... def func(df):
             ...     return df.select(nw.col("a", "b").mean())
 
-            We can then pass either pandas or Polars to `func`:
+            We can pass any supported library such as Pandas, Polars, or PyArrow to `func`:
 
             >>> func(df_pd)
                  a    b
@@ -391,6 +393,13 @@ class Expr:
             ╞═════╪═════╡
             │ 0.0 ┆ 4.0 │
             └─────┴─────┘
+            >>> func(df_pa)
+            pyarrow.Table
+            a: double
+            b: double
+            ----
+            a: [[0]]
+            b: [[4]]
         """
         return self.__class__(lambda plx: self._call(plx).mean())
 
@@ -4054,9 +4063,11 @@ def mean(*columns: str) -> Expr:
     Examples:
         >>> import pandas as pd
         >>> import polars as pl
+        >>> import pyarrow as pa
         >>> import narwhals as nw
         >>> df_pl = pl.DataFrame({"a": [1, 8, 3]})
         >>> df_pd = pd.DataFrame({"a": [1, 8, 3]})
+        >>> df_pa = pa.table({"a": [1, 8, 3]})
 
         We define a dataframe agnostic function:
 
@@ -4064,7 +4075,7 @@ def mean(*columns: str) -> Expr:
         ... def func(df):
         ...     return df.select(nw.mean("a"))
 
-        We can then pass either pandas or Polars to `func`:
+        We can pass any supported library such as Pandas, Polars, or PyArrow to `func`:
 
         >>> func(df_pd)
              a
@@ -4078,6 +4089,11 @@ def mean(*columns: str) -> Expr:
         ╞═════╡
         │ 4.0 │
         └─────┘
+        >>> func(df_pa)
+        pyarrow.Table
+        a: double
+        ----
+        a: [[4]]
     """
 
     return Expr(lambda plx: plx.mean(*columns))
diff --git a/narwhals/stable/v1/__init__.py b/narwhals/stable/v1/__init__.py
index 75da2a42..c09b0f2b 100644
--- a/narwhals/stable/v1/__init__.py
+++ b/narwhals/stable/v1/__init__.py
@@ -51,6 +51,7 @@ from narwhals.stable.v1.dtypes import Unknown
 from narwhals.translate import _from_native_impl
 from narwhals.translate import get_native_namespace as nw_get_native_namespace
 from narwhals.translate import to_native
+from narwhals.translate import to_py_scalar as nw_to_py_scalar
 from narwhals.typing import IntoDataFrameT
 from narwhals.typing import IntoFrameT
 from narwhals.typing import IntoSeriesT
@@ -952,6 +953,28 @@ def narwhalify(
         return decorator(func)
 
 
+def to_py_scalar(scalar: Any) -> Any:
+    """If a scalar is not Python native, converts it to Python native.
+
+    Raises:
+        ValueError: If the object is not convertible to a scalar.
+
+    Examples:
+        >>> import narwhals.stable.v1 as nw
+        >>> import pandas as pd
+        >>> df = nw.from_native(pd.DataFrame({"a": [1, 2, 3]}))
+        >>> nw.to_py_scalar(df["a"].item(0))
+        1
+        >>> import pyarrow as pa
+        >>> df = nw.from_native(pa.table({"a": [1, 2, 3]}))
+        >>> nw.to_py_scalar(df["a"].item(0))
+        1
+        >>> nw.to_py_scalar(1)
+        1
+    """
+    return _stableify(nw_to_py_scalar(scalar))
+
+
 def all() -> Expr:
     """
     Instantiate an expression representing all columns.
@@ -1254,9 +1277,11 @@ def mean(*columns: str) -> Expr:
     Examples:
         >>> import pandas as pd
         >>> import polars as pl
+        >>> import pyarrow as pa
         >>> import narwhals.stable.v1 as nw
         >>> df_pl = pl.DataFrame({"a": [1, 8, 3]})
         >>> df_pd = pd.DataFrame({"a": [1, 8, 3]})
+        >>> df_pa = pa.table({"a": [1, 8, 3]})
 
         We define a dataframe agnostic function:
 
@@ -1264,7 +1289,7 @@ def mean(*columns: str) -> Expr:
         ... def func(df):
         ...     return df.select(nw.mean("a"))
 
-        We can then pass either pandas or Polars to `func`:
+        We can pass any supported library such as Pandas, Polars, or PyArrow to `func`:
 
         >>> func(df_pd)
              a
@@ -1278,6 +1303,11 @@ def mean(*columns: str) -> Expr:
         ╞═════╡
         │ 4.0 │
         └─────┘
+        >>> func(df_pa)
+        pyarrow.Table
+        a: double
+        ----
+        a: [[4]]
     """
     return _stableify(nw.mean(*columns))
 
@@ -2299,6 +2329,7 @@ __all__ = [
     "dependencies",
     "to_native",
     "from_native",
+    "to_py_scalar",
     "is_ordered_categorical",
     "maybe_align_index",
     "maybe_convert_dtypes",
diff --git a/narwhals/translate.py b/narwhals/translate.py
index 0dc0cd46..331b87d8 100644
--- a/narwhals/translate.py
+++ b/narwhals/translate.py
@@ -1,5 +1,8 @@
 from __future__ import annotations
 
+import numbers
+from datetime import datetime
+from datetime import timedelta
 from functools import wraps
 from typing import TYPE_CHECKING
 from typing import Any
@@ -9,9 +12,11 @@ from typing import TypeVar
 from typing import overload
 
 from narwhals.dependencies import get_cudf
+from narwhals.dependencies import get_cupy
 from narwhals.dependencies import get_dask
 from narwhals.dependencies import get_dask_expr
 from narwhals.dependencies import get_modin
+from narwhals.dependencies import get_numpy
 from narwhals.dependencies import get_pandas
 from narwhals.dependencies import get_polars
 from narwhals.dependencies import get_pyarrow
@@ -776,8 +781,70 @@ def narwhalify(
         return decorator(func)
 
 
+def to_py_scalar(scalar_like: Any) -> Any:
+    """If a scalar is not Python native, converts it to Python native.
+
+    Raises:
+        ValueError: If the object is not convertible to a scalar.
+
+    Examples:
+        >>> import narwhals as nw
+        >>> import pandas as pd
+        >>> df = nw.from_native(pd.DataFrame({"a": [1, 2, 3]}))
+        >>> nw.to_py_scalar(df["a"].item(0))
+        1
+        >>> import pyarrow as pa
+        >>> df = nw.from_native(pa.table({"a": [1, 2, 3]}))
+        >>> nw.to_py_scalar(df["a"].item(0))
+        1
+        >>> nw.to_py_scalar(1)
+        1
+    """
+
+    pa = get_pyarrow()
+    if pa and isinstance(scalar_like, pa.Scalar):
+        return scalar_like.as_py()
+
+    cupy = get_cupy()
+    if (  # pragma: no cover
+        cupy and isinstance(scalar_like, cupy.ndarray) and scalar_like.size == 1
+    ):
+        return scalar_like.item()
+
+    np = get_numpy()
+    if np and np.isscalar(scalar_like) and hasattr(scalar_like, "item"):
+        return scalar_like.item()
+
+    pd = get_pandas()
+    if pd and isinstance(scalar_like, pd.Timestamp):
+        return scalar_like.to_pydatetime()
+    if pd and isinstance(scalar_like, pd.Timedelta):
+        return scalar_like.to_pytimedelta()
+
+    all_scalar_types = (
+        int,
+        float,
+        complex,
+        bool,
+        bytes,
+        str,
+        datetime,
+        timedelta,
+        numbers.Number,
+    )
+    if isinstance(scalar_like, all_scalar_types):
+        return scalar_like
+
+    msg = (
+        f"Expected object convertible to a scalar, found {type(scalar_like)}. "
+        "Please report a bug to https://github.com/narwhals-dev/narwhals/issues"
+    )
+    raise ValueError(msg)
+
+
 __all__ = [
     "get_native_namespace",
     "to_native",
     "narwhalify",
+    "to_py_scalar",
 ]
diff --git a/noxfile.py b/noxfile.py
index 1fb820c6..aec70add 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -27,7 +27,9 @@ def run_common(session: Session, coverage_threshold: float) -> None:
         f"--cov-fail-under={coverage_threshold}",
         "--runslow",
     )
-    session.run("pytest", "narwhals", "--doctest-modules")
+
+    if session.python == "3.12":
+        session.run("pytest", "narwhals", "--doctest-modules")
 
 
 @nox.session(python=PYTHON_VERSIONS)  # type: ignore[misc]


Test patch:
diff --git a/.github/workflows/downstream_tests.yml b/.github/workflows/downstream_tests.yml
index 542dacbb..4f9cbe06 100644
--- a/.github/workflows/downstream_tests.yml
+++ b/.github/workflows/downstream_tests.yml
@@ -87,21 +87,16 @@ jobs:
       - name: show-deps
         run: uv pip freeze
       - name: Create assets directory, copy over index.html
+        continue-on-error: true
         run: |
             mkdir -p marimo/marimo/_static/assets
             cp marimo/frontend/index.html marimo/marimo/_static/index.html
             cp marimo/frontend/public/favicon.ico marimo/marimo/_static/favicon.ico
-      - name: Run tests with minimal dependencies
-        if: ${{ matrix.dependencies == 'core' }}
-        run: |
-            cd marimo
-            hatch run +py=${{ matrix.python-version }} test:test -v tests/ -k "not test_cli"
-        timeout-minutes: 15
-      - name: Run tests with optional dependencies
+      - name: Run tests with full dependencies
         if: ${{ matrix.dependencies == 'core,optional' }}
         run: |
             cd marimo
-            hatch run +py=${{ matrix.python-version }} test-optional:test -v tests/ -k "not test_cli"
+            hatch run +py=${{ matrix.python-version }} test-optional:test-narwhals
         timeout-minutes: 15
       - name: Run typechecks
         run: |
diff --git a/tests/expr_and_series/dt/datetime_attributes_test.py b/tests/expr_and_series/dt/datetime_attributes_test.py
index 757d226f..017daace 100644
--- a/tests/expr_and_series/dt/datetime_attributes_test.py
+++ b/tests/expr_and_series/dt/datetime_attributes_test.py
@@ -104,3 +104,15 @@ def test_datetime_chained_attributes(
 
     result = df.select(nw.col("a").dt.date().dt.year())
     compare_dicts(result, {"a": [2021, 2020]})
+
+
+def test_to_date(request: pytest.FixtureRequest, constructor: Constructor) -> None:
+    if any(
+        x in str(constructor)
+        for x in ("pandas_constructor", "pandas_nullable_constructor", "dask")
+    ):
+        request.applymarker(pytest.mark.xfail)
+    dates = {"a": [datetime(2001, 1, 1), None, datetime(2001, 1, 3)]}
+    df = nw.from_native(constructor(dates))
+    result = df.select(nw.col("a").dt.date())
+    assert result.collect_schema() == {"a": nw.Date}
diff --git a/tests/expr_and_series/str/contains_test.py b/tests/expr_and_series/str/contains_test.py
index 2c2e0cb9..98e8ceaa 100644
--- a/tests/expr_and_series/str/contains_test.py
+++ b/tests/expr_and_series/str/contains_test.py
@@ -1,7 +1,5 @@
 from __future__ import annotations
 
-import pandas as pd
-import polars as pl
 import pytest
 
 import narwhals.stable.v1 as nw
@@ -11,9 +9,6 @@ from tests.utils import compare_dicts
 
 data = {"pets": ["cat", "dog", "rabbit and parrot", "dove"]}
 
-df_pandas = pd.DataFrame(data)
-df_polars = pl.DataFrame(data)
-
 
 def test_contains_case_insensitive(
     constructor: Constructor, request: pytest.FixtureRequest
diff --git a/tests/frame/rows_test.py b/tests/frame/rows_test.py
index 7e5c1ece..cdf42648 100644
--- a/tests/frame/rows_test.py
+++ b/tests/frame/rows_test.py
@@ -71,14 +71,43 @@ def test_rows(
     assert result == expected
 
 
-def test_rows_with_nulls_unnamed(constructor_eager: ConstructorEager) -> None:
-    # GIVEN
-    df = nw.from_native(constructor_eager(data_na), eager_only=True)
+@pytest.mark.filterwarnings(
+    r"ignore:.*Starting with pandas version 3\.0 all arguments of to_dict"
+)
+@pytest.mark.parametrize(
+    ("named", "expected"),
+    [
+        (False, [(1, 4, 7.0, 5), (3, 4, 8.0, 6), (2, 6, 9.0, 7)]),
+        (
+            True,
+            [
+                {"a": 1, "_b": 4, "z": 7.0, "1": 5},
+                {"a": 3, "_b": 4, "z": 8.0, "1": 6},
+                {"a": 2, "_b": 6, "z": 9.0, "1": 7},
+            ],
+        ),
+    ],
+)
+def test_rows_eager(
+    constructor_eager: Any,
+    named: bool,  # noqa: FBT001
+    expected: list[tuple[Any, ...]] | list[dict[str, Any]],
+) -> None:
+    # posit-dev/py-shiny relies on `.rows(named=False)` to return unnamed rows
+    data = {"a": [1, 3, 2], "_b": [4, 4, 6], "z": [7.0, 8, 9], "1": [5, 6, 7]}
+    df = nw.from_native(constructor_eager(data), eager_only=True)
+    result = df.rows(named=named)
+    assert result == expected
 
-    # WHEN
-    result = list(df.iter_rows(named=False))
 
-    # THEN
+def test_rows_with_nulls_unnamed(
+    constructor_eager: ConstructorEager, request: pytest.FixtureRequest
+) -> None:
+    if "cudf" in str(constructor_eager):
+        # cudf intentionally doesn't support itertuples / iter_rows
+        request.applymarker(pytest.mark.xfail)
+    df = nw.from_native(constructor_eager(data_na), eager_only=True)
+    result = list(df.iter_rows(named=False))
     expected = [(None, 4, 7.0), (3, 4, None), (2, 6, 9.0)]
     for i, row in enumerate(expected):
         for j, value in enumerate(row):
@@ -89,14 +118,14 @@ def test_rows_with_nulls_unnamed(constructor_eager: ConstructorEager) -> None:
                 assert value_in_result == value
 
 
-def test_rows_with_nulls_named(constructor_eager: ConstructorEager) -> None:
-    # GIVEN
+def test_rows_with_nulls_named(
+    constructor_eager: ConstructorEager, request: pytest.FixtureRequest
+) -> None:
+    if "cudf" in str(constructor_eager):
+        # cudf intentionally doesn't support itertuples / iter_rows
+        request.applymarker(pytest.mark.xfail)
     df = nw.from_native(constructor_eager(data_na), eager_only=True)
-
-    # WHEN
     result = list(df.iter_rows(named=True))
-
-    # THEN
     expected: list[dict[str, Any]] = [
         {"a": None, "b": 4, "z": 7.0},
         {"a": 3, "b": 4, "z": None},
diff --git a/tests/series_only/cast_test.py b/tests/series_only/cast_test.py
index 55752149..c5b37b8c 100644
--- a/tests/series_only/cast_test.py
+++ b/tests/series_only/cast_test.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 
 from datetime import date
 from datetime import datetime
+from typing import TYPE_CHECKING
 
 import pandas as pd
 import polars as pl
@@ -12,16 +13,18 @@ from polars.testing import assert_frame_equal
 import narwhals.stable.v1 as nw
 from narwhals.utils import parse_version
 
+if TYPE_CHECKING:
+    from tests.utils import ConstructorEager
 
-def test_cast_253() -> None:
-    df_polars = pl.DataFrame({"a": [1]})
-    result = nw.from_native(df_polars, eager_only=True).select(
-        nw.col("a").cast(nw.String) + "hi"
-    )["a"][0]
-    assert result == "1hi"
 
-    df_pandas = pd.DataFrame({"a": [1]})
-    result = nw.from_native(df_pandas, eager_only=True).select(
+def test_cast_253(
+    request: pytest.FixtureRequest, constructor_eager: ConstructorEager
+) -> None:
+    if "pyarrow_table" in str(constructor_eager):
+        request.applymarker(pytest.mark.xfail)
+
+    df_raw = constructor_eager({"a": [1]})
+    result = nw.from_native(df_raw, eager_only=True).select(
         nw.col("a").cast(nw.String) + "hi"
     )["a"][0]
     assert result == "1hi"
diff --git a/tests/translate/to_py_scalar_test.py b/tests/translate/to_py_scalar_test.py
new file mode 100644
index 00000000..c9aa2749
--- /dev/null
+++ b/tests/translate/to_py_scalar_test.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+from datetime import datetime
+from datetime import timedelta
+from typing import TYPE_CHECKING
+from typing import Any
+
+import numpy as np
+import pandas as pd
+import pytest
+
+import narwhals.stable.v1 as nw
+from narwhals.dependencies import get_cudf
+
+if TYPE_CHECKING:
+    from tests.utils import ConstructorEager
+
+
+@pytest.mark.parametrize(
+    ("input_value", "expected"),
+    [
+        (1, 1),
+        (1.0, 1.0),
+        ("a", "a"),
+        (True, True),
+        (b"a", b"a"),
+        (datetime(2021, 1, 1), datetime(2021, 1, 1)),
+        (timedelta(days=1), timedelta(days=1)),
+    ],
+)
+def test_to_py_scalar(
+    constructor_eager: ConstructorEager, input_value: Any, expected: Any
+) -> None:
+    df = nw.from_native(constructor_eager({"a": [input_value]}))
+    output = nw.to_py_scalar(df["a"].item(0))
+    if expected == 1 and constructor_eager.__name__.startswith("pandas"):
+        assert not isinstance(output, np.int64)
+    elif isinstance(expected, datetime) and constructor_eager.__name__.startswith(
+        "pandas"
+    ):
+        assert not isinstance(output, pd.Timestamp)
+    elif isinstance(expected, timedelta) and constructor_eager.__name__.startswith(
+        "pandas"
+    ):
+        assert not isinstance(output, pd.Timedelta)
+    assert output == expected
+
+
+@pytest.mark.parametrize(
+    "input_value",
+    [np.array([1, 2]), [1, 2, 3], {"a": [1, 2, 3]}],
+)
+def test_to_py_scalar_value_error(input_value: Any) -> None:
+    with pytest.raises(ValueError, match="Expected object convertible to a scalar"):
+        nw.to_py_scalar(input_value)
+
+
+def test_to_py_scalar_value_error_cudf() -> None:
+    if cudf := get_cudf():  # pragma: no cover
+        df = nw.from_native(cudf.DataFrame({"a": [1, 2, 3]}))
+
+        with pytest.raises(ValueError, match="Expected object convertible to a scalar"):
+            nw.to_py_scalar(df["a"])