Repository: narwhals-dev/narwhals
Pull request #596 (2024-07-23 17:11:52)

feat: pyarrow dataframe `iter_rows`
## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues 

- Related issue #372 

## Checklist

- [x] Code follows style guide (ruff)
- [x] 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 efc825d1..9c2d66ee 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -3,6 +3,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Iterable
+from typing import Iterator
 from typing import Literal
 from typing import Sequence
 from typing import overload
@@ -68,6 +69,23 @@ class ArrowDataFrame:
             raise NotImplementedError(msg)
         return self._native_dataframe.to_pylist()  # type: ignore[no-any-return]
 
+    def iter_rows(
+        self,
+        *,
+        named: bool = False,
+        buffer_size: int = 512,
+    ) -> Iterator[tuple[Any, ...]] | Iterator[dict[str, Any]]:
+        df = self._native_dataframe
+        num_rows = df.num_rows
+
+        if not named:
+            for i in range(0, num_rows, buffer_size):
+                rows = df[i : i + buffer_size].to_pydict().values()
+                yield from zip(*rows)
+        else:
+            for i in range(0, num_rows, buffer_size):
+                yield from df[i : i + buffer_size].to_pylist()
+
     def get_column(self, name: str) -> ArrowSeries:
         from narwhals._arrow.series import ArrowSeries
 
@@ -182,7 +200,9 @@ class ArrowDataFrame:
             if name in new_column_name_to_new_column_map:
                 to_concat.append(
                     validate_dataframe_comparand(
-                        length=length, other=new_column_name_to_new_column_map.pop(name)
+                        length=length,
+                        other=new_column_name_to_new_column_map.pop(name),
+                        backend_version=self._backend_version,
                     )
                 )
             else:
@@ -191,7 +211,9 @@ class ArrowDataFrame:
         for s in new_column_name_to_new_column_map:
             to_concat.append(
                 validate_dataframe_comparand(
-                    length=length, other=new_column_name_to_new_column_map[s]
+                    length=length,
+                    other=new_column_name_to_new_column_map[s],
+                    backend_version=self._backend_version,
                 )
             )
             output_names.append(s)
@@ -371,14 +393,14 @@ class ArrowDataFrame:
                     f" frame has shape {self.shape!r}"
                 )
                 raise ValueError(msg)
-            return self._native_dataframe[0][0].as_py()
+            return self._native_dataframe[0][0]
 
         elif row is None or column is None:
             msg = "cannot call `.item()` with only one of `row` or `column`"
             raise ValueError(msg)
 
         _col = self.columns.index(column) if isinstance(column, str) else column
-        return self._native_dataframe[_col][row].as_py()
+        return self._native_dataframe[_col][row]
 
     def rename(self, mapping: dict[str, str]) -> Self:
         df = self._native_dataframe
@@ -427,7 +449,7 @@ class ArrowDataFrame:
         subset: str | list[str] | None,
         *,
         keep: Literal["any", "first", "last", "none"] = "any",
-        maintain_order: bool = False,  # noqa: ARG002
+        maintain_order: bool = False,
     ) -> Self:
         """
         NOTE:
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 0346fabb..40188959 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -218,8 +218,7 @@ class ArrowSeries:
     def n_unique(self) -> int:
         pc = get_pyarrow_compute()
         unique_values = pc.unique(self._native_series)
-        count_unique = pc.count(unique_values, mode="all")
-        return count_unique.as_py()  # type: ignore[no-any-return]
+        return pc.count(unique_values, mode="all")  # type: ignore[no-any-return]
 
     def __narwhals_namespace__(self) -> ArrowNamespace:
         from narwhals._arrow.namespace import ArrowNamespace
@@ -341,27 +340,39 @@ class ArrowSeries:
                     f" or an explicit index is provided (Series is of length {len(self)})"
                 )
                 raise ValueError(msg)
-            return self._native_series[0].as_py()
-        return self._native_series[index].as_py()
+            return self._native_series[0]
+        return self._native_series[index]
 
-    def value_counts(self: Self, *, sort: bool = False, parallel: bool = False) -> Any:  # noqa: ARG002
+    def value_counts(
+        self: Self,
+        *,
+        sort: bool = False,
+        parallel: bool = False,
+        name: str | None = None,
+        normalize: bool = False,
+    ) -> ArrowDataFrame:
         """Parallel is unused, exists for compatibility"""
         from narwhals._arrow.dataframe import ArrowDataFrame
 
         pc = get_pyarrow_compute()
         pa = get_pyarrow()
 
-        name_ = (
-            "index" if self._native_series._name is None else self._native_series._name
-        )
+        index_name_ = "index" if self._name is None else self._name
+        value_name_ = name or ("proportion" if normalize else "count")
 
         val_count = pc.value_counts(self._native_series)
+        values = val_count.field("values")
+        counts = val_count.field("counts")
+
+        if normalize:
+            counts = pc.divide(*cast_for_truediv(counts, pc.sum(counts)))
+
         val_count = pa.Table.from_arrays(
-            [val_count.field("values"), val_count.field("counts")], names=[name_, "count"]
+            [values, counts], names=[index_name_, value_name_]
         )
 
         if sort:
-            val_count = val_count.sort_by([("count", "descending")])
+            val_count = val_count.sort_by([(value_name_, "descending")])
 
         return ArrowDataFrame(
             val_count,
@@ -464,9 +475,9 @@ class ArrowSeries:
         pc = get_pyarrow_compute()
         ser = self._native_series
         if descending:
-            return pc.all(pc.greater_equal(ser[:-1], ser[1:])).as_py()  # type: ignore[no-any-return]
+            return pc.all(pc.greater_equal(ser[:-1], ser[1:]))  # type: ignore[no-any-return]
         else:
-            return pc.all(pc.less_equal(ser[:-1], ser[1:])).as_py()  # type: ignore[no-any-return]
+            return pc.all(pc.less_equal(ser[:-1], ser[1:]))  # type: ignore[no-any-return]
 
     def unique(self: Self) -> ArrowSeries:
         pc = get_pyarrow_compute()
diff --git a/narwhals/_arrow/utils.py b/narwhals/_arrow/utils.py
index 571a1e33..f98eb2e5 100644
--- a/narwhals/_arrow/utils.py
+++ b/narwhals/_arrow/utils.py
@@ -124,7 +124,9 @@ def validate_column_comparand(other: Any) -> Any:
     return other
 
 
-def validate_dataframe_comparand(length: int, other: Any) -> Any:
+def validate_dataframe_comparand(
+    length: int, other: Any, backend_version: tuple[int, ...]
+) -> Any:
     """Validate RHS of binary operation.
 
     If the comparison isn't supported, return `NotImplemented` so that the
@@ -138,7 +140,10 @@ def validate_dataframe_comparand(length: int, other: Any) -> Any:
     if isinstance(other, ArrowSeries):
         if len(other) == 1:
             pa = get_pyarrow()
-            return pa.chunked_array([[other.item()] * length])
+            value = other.item()
+            if backend_version < (13,) and hasattr(value, "as_py"):  # pragma: no cover
+                value = value.as_py()
+            return pa.chunked_array([[value] * length])
         return other._native_series
     msg = "Please report a bug"  # pragma: no cover
     raise AssertionError(msg)
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index db40fb65..b8645e6e 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -167,7 +167,7 @@ class PandasLikeDataFrame:
         self,
         *,
         named: bool = False,
-        buffer_size: int = 512,  # noqa: ARG002
+        buffer_size: int = 512,
     ) -> Iterator[list[tuple[Any, ...]]] | Iterator[list[dict[str, Any]]]:
         """
         NOTE:
@@ -285,9 +285,9 @@ class PandasLikeDataFrame:
                 backend_version=self._backend_version,
             )
         else:
-            df = self._native_dataframe.assign(
-                **{s.name: validate_dataframe_comparand(index, s) for s in new_columns}
-            )
+            df = self._native_dataframe.copy(deep=False)
+            for s in new_columns:
+                df[s.name] = validate_dataframe_comparand(index, s)
         return self._from_native_dataframe(df)
 
     def rename(self, mapping: dict[str, str]) -> Self:
@@ -454,7 +454,7 @@ class PandasLikeDataFrame:
         subset: str | list[str] | None,
         *,
         keep: Literal["any", "first", "last", "none"] = "any",
-        maintain_order: bool = False,  # noqa: ARG002
+        maintain_order: bool = False,
     ) -> Self:
         """
         NOTE:
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index 946725eb..900995f6 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -195,7 +195,7 @@ class PandasLikeSeries:
             return self._native_series.iloc[0]
         return self._native_series.iloc[index]
 
-    def to_frame(self) -> Any:
+    def to_frame(self) -> PandasLikeDataFrame:
         from narwhals._pandas_like.dataframe import PandasLikeDataFrame
 
         return PandasLikeDataFrame(
@@ -557,17 +557,30 @@ class PandasLikeSeries:
         else:
             return self._native_series.is_monotonic_increasing  # type: ignore[no-any-return]
 
-    def value_counts(self: Self, *, sort: bool = False, parallel: bool = False) -> Any:  # noqa: ARG002
+    def value_counts(
+        self: Self,
+        *,
+        sort: bool = False,
+        parallel: bool = False,
+        name: str | None = None,
+        normalize: bool = False,
+    ) -> PandasLikeDataFrame:
         """Parallel is unused, exists for compatibility"""
         from narwhals._pandas_like.dataframe import PandasLikeDataFrame
 
-        name_ = "index" if self._native_series.name is None else self._native_series.name
+        index_name_ = "index" if self._name is None else self._name
+        value_name_ = name or ("proportion" if normalize else "count")
+
         val_count = self._native_series.value_counts(
-            dropna=False, sort=False
+            dropna=False,
+            sort=False,
+            normalize=normalize,
         ).reset_index()
-        val_count.columns = [name_, "count"]
+
+        val_count.columns = [index_name_, value_name_]
+
         if sort:
-            val_count = val_count.sort_values("count", ascending=False)
+            val_count = val_count.sort_values(value_name_, ascending=False)
 
         return PandasLikeDataFrame(
             val_count,
diff --git a/narwhals/_polars/series.py b/narwhals/_polars/series.py
index 880edbb2..d64d4fe9 100644
--- a/narwhals/_polars/series.py
+++ b/narwhals/_polars/series.py
@@ -199,6 +199,37 @@ class PolarsSeries:
 
         return self._from_native_series(result)
 
+    def value_counts(
+        self: Self,
+        *,
+        sort: bool = False,
+        parallel: bool = False,
+        name: str | None = None,
+        normalize: bool = False,
+    ) -> PolarsDataFrame:
+        from narwhals._polars.dataframe import PolarsDataFrame
+
+        if self._backend_version < (1, 0, 0):  # pragma: no cover
+            pl = get_polars()
+            value_name_ = name or ("proportion" if normalize else "count")
+
+            result = self._native_series.value_counts(sort=sort, parallel=parallel)
+            result = result.select(
+                **{
+                    (self._native_series.name): pl.col(self._native_series.name),
+                    value_name_: pl.col("count") / pl.sum("count")
+                    if normalize
+                    else pl.col("count"),
+                }
+            )
+
+        else:
+            result = self._native_series.value_counts(
+                sort=sort, parallel=parallel, name=name, normalize=normalize
+            )
+
+        return PolarsDataFrame(result, backend_version=self._backend_version)
+
     @property
     def dt(self) -> PolarsSeriesDateTimeNamespace:
         return PolarsSeriesDateTimeNamespace(self)
diff --git a/narwhals/series.py b/narwhals/series.py
index 9e0e8c17..1dd54480 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -1654,7 +1654,12 @@ class Series:
         return self._compliant_series.is_sorted(descending=descending)  # type: ignore[no-any-return]
 
     def value_counts(
-        self: Self, *, sort: bool = False, parallel: bool = False
+        self: Self,
+        *,
+        sort: bool = False,
+        parallel: bool = False,
+        name: str | None = None,
+        normalize: bool = False,
     ) -> DataFrame[Any]:
         r"""
         Count the occurrences of unique values.
@@ -1662,7 +1667,10 @@ class Series:
         Arguments:
             sort: Sort the output by count in descending order. If set to False (default),
                 the order of the output is random.
-            parallel: Execute the computation in parallel. Unused for pandas-like APIs.
+            parallel: Execute the computation in parallel. Used for Polars only.
+            name: Give the resulting count column a specific name; if `normalize` is True
+                defaults to "proportion", otherwise defaults to "count".
+            normalize: If true gives relative frequencies of the unique values
 
         Examples:
             >>> import narwhals as nw
@@ -1700,7 +1708,9 @@ class Series:
         from narwhals.dataframe import DataFrame
 
         return DataFrame(
-            self._compliant_series.value_counts(sort=sort, parallel=parallel),
+            self._compliant_series.value_counts(
+                sort=sort, parallel=parallel, name=name, normalize=normalize
+            ),
             level=self._level,
         )
 
diff --git a/narwhals/stable/v1.py b/narwhals/stable/v1.py
index 18f520bd..9ca7c639 100644
--- a/narwhals/stable/v1.py
+++ b/narwhals/stable/v1.py
@@ -361,7 +361,12 @@ class Series(NwSeries):
         return _stableify(super().to_frame())  # type: ignore[no-any-return]
 
     def value_counts(
-        self: Self, *, sort: bool = False, parallel: bool = False
+        self: Self,
+        *,
+        sort: bool = False,
+        parallel: bool = False,
+        name: str | None = None,
+        normalize: bool = False,
     ) -> DataFrame[Any]:
         r"""
         Count the occurrences of unique values.
@@ -369,7 +374,10 @@ class Series(NwSeries):
         Arguments:
             sort: Sort the output by count in descending order. If set to False (default),
                 the order of the output is random.
-            parallel: Execute the computation in parallel. Unused for pandas-like APIs.
+            parallel: Execute the computation in parallel. Used for Polars only.
+            name: Give the resulting count column a specific name; if `normalize` is True
+                defaults to "proportion", otherwise defaults to "count".
+            normalize: If true gives relative frequencies of the unique values
 
         Examples:
             >>> import narwhals.stable.v1 as nw
@@ -404,7 +412,11 @@ class Series(NwSeries):
             │ 3   ┆ 1     │
             └─────┴───────┘
         """
-        return _stableify(super().value_counts(sort=sort, parallel=parallel))  # type: ignore[no-any-return]
+        return _stableify(  # type: ignore[no-any-return]
+            super().value_counts(
+                sort=sort, parallel=parallel, name=name, normalize=normalize
+            )
+        )
 
 
 class Expr(NwExpr):
diff --git a/narwhals/translate.py b/narwhals/translate.py
index 24481d20..f5b76dea 100644
--- a/narwhals/translate.py
+++ b/narwhals/translate.py
@@ -307,102 +307,53 @@ def from_native(  # noqa: PLR0915
         msg = "Invalid parameter combination: `eager_only=True` and `eager_or_interchange_only=True`"
         raise ValueError(msg)
 
-    if (pl := get_polars()) is not None and isinstance(native_object, pl.DataFrame):
+    # Extensions
+    if hasattr(native_object, "__narwhals_dataframe__"):
         if series_only:
-            msg = "Cannot only use `series_only` with polars.DataFrame"
+            msg = "Cannot only use `series_only` with dataframe"
             raise TypeError(msg)
         return DataFrame(
-            PolarsDataFrame(native_object, backend_version=parse_version(pl.__version__)),
+            native_object.__narwhals_dataframe__(),
             level="full",
         )
-    elif (pl := get_polars()) is not None and isinstance(native_object, pl.LazyFrame):
+    elif hasattr(native_object, "__narwhals_lazyframe__"):
         if series_only:
-            msg = "Cannot only use `series_only` with polars.LazyFrame"
+            msg = "Cannot only use `series_only` with lazyframe"
             raise TypeError(msg)
         if eager_only or eager_or_interchange_only:
-            msg = "Cannot only use `eager_only` or `eager_or_interchange_only` with polars.LazyFrame"
+            msg = "Cannot only use `eager_only` or `eager_or_interchange_only` with lazyframe"
             raise TypeError(msg)
         return LazyFrame(
-            PolarsLazyFrame(native_object, backend_version=parse_version(pl.__version__)),
-            level="full",
-        )
-    elif (pd := get_pandas()) is not None and isinstance(native_object, pd.DataFrame):
-        if series_only:
-            msg = "Cannot only use `series_only` with dataframe"
-            raise TypeError(msg)
-        return DataFrame(
-            PandasLikeDataFrame(
-                native_object,
-                backend_version=parse_version(pd.__version__),
-                implementation=Implementation.PANDAS,
-            ),
-            level="full",
-        )
-    elif (mpd := get_modin()) is not None and isinstance(native_object, mpd.DataFrame):
-        if series_only:
-            msg = "Cannot only use `series_only` with modin.DataFrame"
-            raise TypeError(msg)
-        return DataFrame(
-            PandasLikeDataFrame(
-                native_object,
-                implementation=Implementation.MODIN,
-                backend_version=parse_version(mpd.__version__),
-            ),
-            level="full",
-        )
-    elif (cudf := get_cudf()) is not None and isinstance(  # pragma: no cover
-        native_object, cudf.DataFrame
-    ):
-        if series_only:
-            msg = "Cannot only use `series_only` with modin.DataFrame"
-            raise TypeError(msg)
-        return DataFrame(
-            PandasLikeDataFrame(
-                native_object,
-                implementation=Implementation.CUDF,
-                backend_version=parse_version(cudf.__version__),
-            ),
+            native_object.__narwhals_lazyframe__(),
             level="full",
         )
-    elif (pa := get_pyarrow()) is not None and isinstance(native_object, pa.Table):
-        if series_only:
-            msg = "Cannot only use `series_only` with arrow table"
+    elif hasattr(native_object, "__narwhals_series__"):
+        if not allow_series:
+            msg = "Please set `allow_series=True`"
             raise TypeError(msg)
-        return DataFrame(
-            ArrowDataFrame(native_object, backend_version=parse_version(pa.__version__)),
+        return Series(
+            native_object.__narwhals_series__(),
             level="full",
         )
-    elif hasattr(native_object, "__dataframe__"):
-        if eager_only or series_only:
-            msg = (
-                "Cannot only use `series_only=True` or `eager_only=False` "
-                "with object which only implements __dataframe__"
-            )
-            raise TypeError(msg)
-        # placeholder (0,) version here, as we wouldn't use it in this case anyway.
-        return DataFrame(
-            InterchangeFrame(native_object.__dataframe__()),
-            level="interchange",
-        )
-    elif hasattr(native_object, "__narwhals_dataframe__"):
+
+    # Polars
+    elif (pl := get_polars()) is not None and isinstance(native_object, pl.DataFrame):
         if series_only:
-            msg = "Cannot only use `series_only` with dataframe"
+            msg = "Cannot only use `series_only` with polars.DataFrame"
             raise TypeError(msg)
-        # placeholder (0,) version here, as we wouldn't use it in this case anyway.
         return DataFrame(
-            native_object.__narwhals_dataframe__(),
+            PolarsDataFrame(native_object, backend_version=parse_version(pl.__version__)),
             level="full",
         )
-    elif hasattr(native_object, "__narwhals_lazyframe__"):
+    elif (pl := get_polars()) is not None and isinstance(native_object, pl.LazyFrame):
         if series_only:
-            msg = "Cannot only use `series_only` with lazyframe"
+            msg = "Cannot only use `series_only` with polars.LazyFrame"
             raise TypeError(msg)
         if eager_only or eager_or_interchange_only:
-            msg = "Cannot only use `eager_only` or `eager_or_interchange_only` with lazyframe"
+            msg = "Cannot only use `eager_only` or `eager_or_interchange_only` with polars.LazyFrame"
             raise TypeError(msg)
-        # placeholder (0,) version here, as we wouldn't use it in this case anyway.
         return LazyFrame(
-            native_object.__narwhals_lazyframe__(),
+            PolarsLazyFrame(native_object, backend_version=parse_version(pl.__version__)),
             level="full",
         )
     elif (pl := get_polars()) is not None and isinstance(native_object, pl.Series):
@@ -413,6 +364,20 @@ def from_native(  # noqa: PLR0915
             PolarsSeries(native_object, backend_version=parse_version(pl.__version__)),
             level="full",
         )
+
+    # pandas
+    elif (pd := get_pandas()) is not None and isinstance(native_object, pd.DataFrame):
+        if series_only:
+            msg = "Cannot only use `series_only` with dataframe"
+            raise TypeError(msg)
+        return DataFrame(
+            PandasLikeDataFrame(
+                native_object,
+                backend_version=parse_version(pd.__version__),
+                implementation=Implementation.PANDAS,
+            ),
+            level="full",
+        )
     elif (pd := get_pandas()) is not None and isinstance(native_object, pd.Series):
         if not allow_series:
             msg = "Please set `allow_series=True`"
@@ -425,6 +390,20 @@ def from_native(  # noqa: PLR0915
             ),
             level="full",
         )
+
+    # Modin
+    elif (mpd := get_modin()) is not None and isinstance(native_object, mpd.DataFrame):
+        if series_only:
+            msg = "Cannot only use `series_only` with modin.DataFrame"
+            raise TypeError(msg)
+        return DataFrame(
+            PandasLikeDataFrame(
+                native_object,
+                implementation=Implementation.MODIN,
+                backend_version=parse_version(mpd.__version__),
+            ),
+            level="full",
+        )
     elif (mpd := get_modin()) is not None and isinstance(native_object, mpd.Series):
         if not allow_series:
             msg = "Please set `allow_series=True`"
@@ -437,6 +416,22 @@ def from_native(  # noqa: PLR0915
             ),
             level="full",
         )
+
+    # cuDF
+    elif (cudf := get_cudf()) is not None and isinstance(  # pragma: no cover
+        native_object, cudf.DataFrame
+    ):
+        if series_only:
+            msg = "Cannot only use `series_only` with cudf.DataFrame"
+            raise TypeError(msg)
+        return DataFrame(
+            PandasLikeDataFrame(
+                native_object,
+                implementation=Implementation.CUDF,
+                backend_version=parse_version(cudf.__version__),
+            ),
+            level="full",
+        )
     elif (cudf := get_cudf()) is not None and isinstance(
         native_object, cudf.Series
     ):  # pragma: no cover
@@ -451,6 +446,16 @@ def from_native(  # noqa: PLR0915
             ),
             level="full",
         )
+
+    # PyArrow
+    elif (pa := get_pyarrow()) is not None and isinstance(native_object, pa.Table):
+        if series_only:
+            msg = "Cannot only use `series_only` with arrow table"
+            raise TypeError(msg)
+        return DataFrame(
+            ArrowDataFrame(native_object, backend_version=parse_version(pa.__version__)),
+            level="full",
+        )
     elif (pa := get_pyarrow()) is not None and isinstance(native_object, pa.ChunkedArray):
         if not allow_series:
             msg = "Please set `allow_series=True`"
@@ -461,15 +466,20 @@ def from_native(  # noqa: PLR0915
             ),
             level="full",
         )
-    elif hasattr(native_object, "__narwhals_series__"):
-        if not allow_series:
-            msg = "Please set `allow_series=True`"
+
+    # Interchange protocol
+    elif hasattr(native_object, "__dataframe__"):
+        if eager_only or series_only:
+            msg = (
+                "Cannot only use `series_only=True` or `eager_only=False` "
+                "with object which only implements __dataframe__"
+            )
             raise TypeError(msg)
-        # placeholder (0,) version here, as we wouldn't use it in this case anyway.
-        return Series(
-            native_object.__narwhals_series__(),
-            level="full",
+        return DataFrame(
+            InterchangeFrame(native_object.__dataframe__()),
+            level="interchange",
         )
+
     elif strict:
         msg = f"Expected pandas-like dataframe, Polars dataframe, or Polars lazyframe, got: {type(native_object)}"
         raise TypeError(msg)
diff --git a/pyproject.toml b/pyproject.toml
index e392abc1..d9fe8f4f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -44,6 +44,7 @@ lint.select = [
 ]
 lint.ignore = [
   "A001",
+  "ARG002",
   "ANN101",
   "ANN401",
   "C901",


Test patch:
diff --git a/tests/expr_and_series/n_unique_test.py b/tests/expr_and_series/n_unique_test.py
index 98dd0a85..24c2c889 100644
--- a/tests/expr_and_series/n_unique_test.py
+++ b/tests/expr_and_series/n_unique_test.py
@@ -17,6 +17,5 @@ def test_n_unique(constructor: Any) -> None:
         "b": [4],
     }
     compare_dicts(result, expected)
-    assert df["a"].n_unique() == 3
-    assert df["b"].n_unique() == 4
-    compare_dicts(result, expected)
+    result_series = {"a": [df["a"].n_unique()], "b": [df["b"].n_unique()]}
+    compare_dicts(result_series, expected)
diff --git a/tests/expr_and_series/str/to_datetime_test.py b/tests/expr_and_series/str/to_datetime_test.py
index ea248bde..b95c2d9a 100644
--- a/tests/expr_and_series/str/to_datetime_test.py
+++ b/tests/expr_and_series/str/to_datetime_test.py
@@ -1,4 +1,3 @@
-from datetime import datetime
 from typing import Any
 
 import narwhals.stable.v1 as nw
@@ -12,4 +11,4 @@ def test_to_datetime(constructor: Any) -> None:
         .select(b=nw.col("a").str.to_datetime(format="%Y-%m-%dT%H:%M:%S"))
         .item(row=0, column="b")
     )
-    assert result == datetime(2020, 1, 1, 12, 34, 56)
+    assert str(result) == "2020-01-01 12:34:56"
diff --git a/tests/frame/item_test.py b/tests/frame/item_test.py
index 7471937e..75f4ec86 100644
--- a/tests/frame/item_test.py
+++ b/tests/frame/item_test.py
@@ -6,6 +6,7 @@ from typing import Any
 import pytest
 
 import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
 
 
 @pytest.mark.parametrize(
@@ -17,8 +18,8 @@ def test_item(
 ) -> None:
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
     df = nw.from_native(constructor(data), eager_only=True)
-    assert df.item(row, column) == expected
-    assert df.select("a").head(1).item() == 1
+    compare_dicts({"a": [df.item(row, column)]}, {"a": [expected]})
+    compare_dicts({"a": [df.select("a").head(1).item()]}, {"a": [1]})
 
 
 @pytest.mark.parametrize(
diff --git a/tests/frame/rows_test.py b/tests/frame/rows_test.py
index 9d60f48a..92fea53e 100644
--- a/tests/frame/rows_test.py
+++ b/tests/frame/rows_test.py
@@ -40,9 +40,6 @@ df_pandas_na = pd.DataFrame({"a": [None, 3, 2], "b": [4, 4, 6], "z": [7.0, None,
 df_polars_na = pl.DataFrame({"a": [None, 3, 2], "b": [4, 4, 6], "z": [7.0, None, 9]})
 
 
-@pytest.mark.parametrize(
-    "df_raw", [df_pandas, df_pandas_nullable, df_pandas_pyarrow, df_polars]
-)
 @pytest.mark.parametrize(
     ("named", "expected"),
     [
@@ -58,11 +55,12 @@ df_polars_na = pl.DataFrame({"a": [None, 3, 2], "b": [4, 4, 6], "z": [7.0, None,
     ],
 )
 def test_iter_rows(
-    df_raw: Any,
+    constructor: Any,
     named: bool,  # noqa: FBT001
     expected: list[tuple[Any, ...]] | list[dict[str, Any]],
 ) -> None:
-    df = nw.from_native(df_raw, eager_only=True)
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    df = nw.from_native(constructor(data), eager_only=True)
     result = list(df.iter_rows(named=named))
     assert result == expected
 
diff --git a/tests/frame/with_columns_test.py b/tests/frame/with_columns_test.py
new file mode 100644
index 00000000..605688fa
--- /dev/null
+++ b/tests/frame/with_columns_test.py
@@ -0,0 +1,15 @@
+import numpy as np
+import pandas as pd
+
+import narwhals as nw
+
+
+def test_with_columns_int_col_name_pandas() -> None:
+    np_matrix = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
+    df = pd.DataFrame(np_matrix, dtype="int64")
+    nw_df = nw.from_native(df, eager_only=True)
+    result = nw_df.with_columns(nw_df.get_column(1).alias(4)).pipe(nw.to_native)  # type: ignore[arg-type]
+    expected = pd.DataFrame(
+        {0: [1, 4, 7], 1: [2, 5, 8], 2: [3, 6, 9], 4: [2, 5, 8]}, dtype="int64"
+    )
+    pd.testing.assert_frame_equal(result, expected)
diff --git a/tests/series_only/is_sorted_test.py b/tests/series_only/is_sorted_test.py
index 7861796d..13b66999 100644
--- a/tests/series_only/is_sorted_test.py
+++ b/tests/series_only/is_sorted_test.py
@@ -5,6 +5,7 @@ from typing import Any
 import pytest
 
 import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
 
 data = [1, 3, 2]
 data_dups = [4, 4, 6]
@@ -23,7 +24,7 @@ def test_is_sorted(
 ) -> None:
     series = nw.from_native(constructor_series(input_data), series_only=True)
     result = series.is_sorted(descending=descending)
-    assert result == expected
+    compare_dicts({"a": [result]}, {"a": [expected]})
 
 
 def test_is_sorted_invalid(constructor_series: Any) -> None:
diff --git a/tests/series_only/test_common.py b/tests/series_only/test_common.py
index 499bd652..4d9330b1 100644
--- a/tests/series_only/test_common.py
+++ b/tests/series_only/test_common.py
@@ -12,6 +12,7 @@ from pandas.testing import assert_series_equal
 
 import narwhals.stable.v1 as nw
 from narwhals.utils import parse_version
+from tests.utils import compare_dicts
 
 data = [1, 3, 2]
 data_dups = [4, 4, 6]
@@ -167,8 +168,8 @@ def test_cast_string() -> None:
 def test_item(constructor_series: Any, index: int, expected: int) -> None:
     series = nw.from_native(constructor_series(data), series_only=True)
     result = series.item(index)
-    assert result == expected
-    assert series.head(1).item() == 1
+    compare_dicts({"a": [result]}, {"a": [expected]})
+    compare_dicts({"a": [series.head(1).item()]}, {"a": [1]})
 
     with pytest.raises(
         ValueError,
diff --git a/tests/series_only/value_counts_test.py b/tests/series_only/value_counts_test.py
index abda5979..f6b5dc96 100644
--- a/tests/series_only/value_counts_test.py
+++ b/tests/series_only/value_counts_test.py
@@ -1,26 +1,43 @@
-import sys
+from __future__ import annotations
+
 from typing import Any
 
+import pandas as pd
 import pytest
 
 import narwhals.stable.v1 as nw
+from narwhals.utils import parse_version
 from tests.utils import compare_dicts
 
-data = {"a": [4, 4, 6, 4, 1, 1]}
+data = [4, 4, 4, 1, 6, 6, 4, 4, 1, 1]
 
 
-def test_value_counts(request: Any, constructor: Any) -> None:
-    if "pandas_nullable_constructor" in str(constructor) and sys.version_info < (
-        3,
-        9,
-    ):  # fails for py3.8
+@pytest.mark.parametrize("normalize", [True, False])
+@pytest.mark.parametrize("name", [None, "count_name"])
+def test_value_counts(
+    request: Any, constructor_series: Any, normalize: Any, name: str | None
+) -> None:
+    if "pandas_series_nullable_constructor" in str(constructor_series) and parse_version(
+        pd.__version__
+    ) < (2, 2):
+        # bug in old pandas
         request.applymarker(pytest.mark.xfail)
 
-    series = nw.from_native(constructor(data), eager_only=True)["a"]
+    expected_count = [5, 3, 2]
+    expected_index = [4, 1, 6]
+
+    if normalize:
+        expected_count = [v / len(data) for v in expected_count]  # type: ignore[misc]
+
+    expected_name = name or ("proportion" if normalize else "count")
+    expected = {"a": expected_index, expected_name: expected_count}
+
+    series = nw.from_native(constructor_series(data), series_only=True).alias("a")
 
-    sorted_result = series.value_counts(sort=True)
-    expected = {"a": [4, 1, 6], "count": [3, 2, 1]}
+    sorted_result = series.value_counts(sort=True, name=name, normalize=normalize)
     compare_dicts(sorted_result, expected)
 
-    unsorted_result = series.value_counts(sort=False).sort("count", descending=True)
+    unsorted_result = series.value_counts(
+        sort=False, name=name, normalize=normalize
+    ).sort(expected_name, descending=True)
     compare_dicts(unsorted_result, expected)