Repository: narwhals-dev/narwhals
Pull request #457 (2024-07-09 09:29:17)

feat: implement min and max for pyarrow
<!--
# 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 # 
- Closes #

## Checklist

- [ ] Code follows style guide (ruff)
- [ ] Tests added 
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below.



MarcoGorelli: hi @brentomagic - looks like you added an empty file?
brentomagic: my bad, let me correct that.
MarcoGorelli: hey @brentomagic - looks like you're missing several imports, you should see if you can run this locally
MarcoGorelli: thanks @brentomagic ! the test runs, but now we get
```
E   AttributeError: 'ArrowNamespace' object has no attribute 'min'
```

Could you add that please? Take a look at `PandasLikeNamespace`, see how `min` is implemented there, and try to add it
MarcoGorelli: looking good!!!

looks like you're just missing the implementation for `max`?
```
FAILED tests/expr/reductions_test.py::test_expr_min_max[table] - AttributeError: 'ArrowExpr' object has no attribute 'max'
```


Patch:
diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml
index 0192e7cb..74b6b44e 100644
--- a/.github/release-drafter.yml
+++ b/.github/release-drafter.yml
@@ -2,7 +2,6 @@ exclude-labels:
   - skip changelog
   - release
 name-template: 'Narwhals v$RESOLVED_VERSION'
-tag-template: 'v$RESOLVED_VERSION'
 
 change-template: '- $TITLE (#$NUMBER)' 
     
diff --git a/docs/installation.md b/docs/installation.md
index fe13f3d4..ce0298f7 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -11,6 +11,6 @@ Then, if you start the Python REPL and see the following:
 ```python
 >>> import narwhals
 >>> narwhals.__version__
-'1.0.4'
+'1.0.5'
 ```
 then installation worked correctly!
diff --git a/narwhals/__init__.py b/narwhals/__init__.py
index a7aa8cc9..f0d116a8 100644
--- a/narwhals/__init__.py
+++ b/narwhals/__init__.py
@@ -43,7 +43,7 @@ from narwhals.utils import maybe_align_index
 from narwhals.utils import maybe_convert_dtypes
 from narwhals.utils import maybe_set_index
 
-__version__ = "1.0.4"
+__version__ = "1.0.5"
 
 __all__ = [
     "selectors",
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index 525402b8..fa03b625 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -239,6 +239,31 @@ class ArrowDataFrame:
         row_indices = pa.array(range(df.num_rows))
         return self._from_native_dataframe(df.append_column(name, row_indices))
 
+    def null_count(self) -> Self:
+        pa = get_pyarrow()
+        df = self._native_dataframe
+        names_and_values = zip(df.column_names, df.columns)
+
+        return self._from_native_dataframe(
+            pa.table({name: [col.null_count] for name, col in names_and_values})
+        )
+
+    def head(self, n: int) -> Self:
+        df = self._native_dataframe
+        if n >= 0:
+            return self._from_native_dataframe(df.slice(0, n))
+        else:
+            num_rows = df.num_rows
+            return self._from_native_dataframe(df.slice(0, max(0, num_rows + n)))
+
+    def tail(self, n: int) -> Self:
+        df = self._native_dataframe
+        if n >= 0:
+            num_rows = df.num_rows
+            return self._from_native_dataframe(df.slice(max(0, num_rows - n)))
+        else:
+            return self._from_native_dataframe(df.slice(abs(n)))
+
     def lazy(self) -> Self:
         return self
 
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index 88bd32c3..45c0c719 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -107,6 +107,12 @@ class ArrowExpr:
     def any(self) -> Self:
         return reuse_series_implementation(self, "any", returns_scalar=True)
 
+    def min(self) -> Self:
+        return reuse_series_implementation(self, "min", returns_scalar=True)
+
+    def max(self) -> Self:
+        return reuse_series_implementation(self, "max", returns_scalar=True)
+
     def all(self) -> Self:
         return reuse_series_implementation(self, "all", returns_scalar=True)
 
@@ -122,6 +128,15 @@ class ArrowExpr:
             backend_version=self._backend_version,
         )
 
+    def null_count(self) -> Self:
+        return reuse_series_implementation(self, "null_count", returns_scalar=True)
+
+    def head(self, n: int) -> Self:
+        return reuse_series_implementation(self, "head", n)
+
+    def tail(self, n: int) -> Self:
+        return reuse_series_implementation(self, "tail", n)
+
     @property
     def dt(self) -> ArrowExprDateTimeNamespace:
         return ArrowExprDateTimeNamespace(self)
diff --git a/narwhals/_arrow/namespace.py b/narwhals/_arrow/namespace.py
index 36af9f1f..14f73381 100644
--- a/narwhals/_arrow/namespace.py
+++ b/narwhals/_arrow/namespace.py
@@ -73,7 +73,7 @@ class ArrowNamespace:
     def _create_series_from_scalar(self, value: Any, series: ArrowSeries) -> ArrowSeries:
         from narwhals._arrow.series import ArrowSeries
 
-        if self._backend_version < (13,):  # pragma: no cover
+        if self._backend_version < (13,) and hasattr(value, "as_py"):  # pragma: no cover
             value = value.as_py()
         return ArrowSeries._from_iterable(
             [value],
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 286bb37d..4bfa49e2 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -74,6 +74,14 @@ class ArrowSeries:
         pc = get_pyarrow_compute()
         return pc.mean(self._native_series)  # type: ignore[no-any-return]
 
+    def min(self) -> int:
+        pc = get_pyarrow_compute()
+        return pc.min(self._native_series)  # type: ignore[no-any-return]
+
+    def max(self) -> int:
+        pc = get_pyarrow_compute()
+        return pc.max(self._native_series)  # type: ignore[no-any-return]
+
     def std(self, ddof: int = 1) -> int:
         pc = get_pyarrow_compute()
         return pc.stddev(self._native_series, ddof=ddof)  # type: ignore[no-any-return]
@@ -144,6 +152,25 @@ class ArrowSeries:
         dtype = reverse_translate_dtype(dtype)
         return self._from_native_series(pc.cast(ser, dtype))
 
+    def null_count(self: Self) -> int:
+        return self._native_series.null_count  # type: ignore[no-any-return]
+
+    def head(self, n: int) -> Self:
+        ser = self._native_series
+        if n >= 0:
+            return self._from_native_series(ser.slice(0, n))
+        else:
+            num_rows = len(ser)
+            return self._from_native_series(ser.slice(0, max(0, num_rows + n)))
+
+    def tail(self, n: int) -> Self:
+        ser = self._native_series
+        if n >= 0:
+            num_rows = len(ser)
+            return self._from_native_series(ser.slice(max(0, num_rows - n)))
+        else:
+            return self._from_native_series(ser.slice(abs(n)))
+
     @property
     def shape(self) -> tuple[int]:
         return (len(self._native_series),)
diff --git a/narwhals/_arrow/utils.py b/narwhals/_arrow/utils.py
index a6a01fdf..a031c156 100644
--- a/narwhals/_arrow/utils.py
+++ b/narwhals/_arrow/utils.py
@@ -140,4 +140,5 @@ def validate_dataframe_comparand(other: Any) -> Any:
             msg = "not implemented yet"  # pragma: no cover
             raise NotImplementedError(msg)
         return other._native_series
-    raise AssertionError("Please report a bug")
+    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 6be62d3e..bbfcacea 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -81,7 +81,8 @@ class PandasLikeDataFrame:
                     raise ValueError(
                         msg,
                     )
-            raise AssertionError("Pls report bug")
+            msg = "Please report a bug"  # pragma: no cover
+            raise AssertionError(msg)
 
     def _from_native_dataframe(self, df: Any) -> Self:
         return self.__class__(
diff --git a/narwhals/_pandas_like/group_by.py b/narwhals/_pandas_like/group_by.py
index f5511785..f4e3ef95 100644
--- a/narwhals/_pandas_like/group_by.py
+++ b/narwhals/_pandas_like/group_by.py
@@ -146,9 +146,8 @@ def agg_pandas(
         try:
             result_simple = grouped.agg(aggs)
         except AttributeError as exc:
-            raise RuntimeError(
-                "Failed to aggregated - does your aggregation function return a scalar?"
-            ) from exc
+            msg = "Failed to aggregated - does your aggregation function return a scalar?"
+            raise RuntimeError(msg) from exc
         result_simple.columns = [f"{a}_{b}" for a, b in result_simple.columns]
         result_simple = result_simple.rename(columns=name_mapping).reset_index()
         return from_dataframe(result_simple.loc[:, output_names])
diff --git a/narwhals/_pandas_like/utils.py b/narwhals/_pandas_like/utils.py
index dd77d2f5..b9f6050f 100644
--- a/narwhals/_pandas_like/utils.py
+++ b/narwhals/_pandas_like/utils.py
@@ -87,7 +87,8 @@ def validate_dataframe_comparand(index: Any, other: Any) -> Any:
                 backend_version=other._backend_version,
             )
         return other._native_series
-    raise AssertionError("Please report a bug")
+    msg = "Please report a bug"  # pragma: no cover
+    raise AssertionError(msg)
 
 
 def create_native_series(
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index e8638036..d5cec6a0 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -1955,7 +1955,8 @@ class LazyFrame(BaseFrame[FrameT]):
         )
 
     def __getitem__(self, item: str | slice) -> Series | Self:
-        raise TypeError("Slicing is not supported on LazyFrame")
+        msg = "Slicing is not supported on LazyFrame"
+        raise TypeError(msg)
 
     def collect(self) -> DataFrame[Any]:
         r"""
diff --git a/narwhals/expression.py b/narwhals/expression.py
index 95346dbf..7703952a 100644
--- a/narwhals/expression.py
+++ b/narwhals/expression.py
@@ -3357,10 +3357,11 @@ def lit(value: Any, dtype: DType | None = None) -> Expr:
 
     """
     if (np := get_numpy()) is not None and isinstance(value, np.ndarray):
-        raise ValueError(
+        msg = (
             "numpy arrays are not supported as literal values. "
             "Consider using `with_columns` to create a new column from the array."
         )
+        raise ValueError(msg)
 
     if isinstance(value, (list, tuple)):
         msg = f"Nested datatypes are not supported yet. Got {value}"
diff --git a/narwhals/functions.py b/narwhals/functions.py
index 172fe3b6..c7e2b534 100644
--- a/narwhals/functions.py
+++ b/narwhals/functions.py
@@ -28,7 +28,8 @@ def concat(
             "Only horizontal and vertical concatenations are supported"
         )
     if not items:
-        raise ValueError("No items to concatenate")
+        msg = "No items to concatenate"
+        raise ValueError(msg)
     items = list(items)
     validate_same_library(items)
     validate_laziness(items)
diff --git a/narwhals/translate.py b/narwhals/translate.py
index a7285700..8b243d33 100644
--- a/narwhals/translate.py
+++ b/narwhals/translate.py
@@ -265,7 +265,8 @@ def from_native(  # noqa: PLR0915
 
     if (pl := get_polars()) is not None and isinstance(native_dataframe, pl.DataFrame):
         if series_only:  # pragma: no cover (todo)
-            raise TypeError("Cannot only use `series_only` with polars.DataFrame")
+            msg = "Cannot only use `series_only` with polars.DataFrame"
+            raise TypeError(msg)
         return DataFrame(
             native_dataframe,
             is_polars=True,
@@ -273,9 +274,11 @@ def from_native(  # noqa: PLR0915
         )
     elif (pl := get_polars()) is not None and isinstance(native_dataframe, pl.LazyFrame):
         if series_only:  # pragma: no cover (todo)
-            raise TypeError("Cannot only use `series_only` with polars.LazyFrame")
+            msg = "Cannot only use `series_only` with polars.LazyFrame"
+            raise TypeError(msg)
         if eager_only:  # pragma: no cover (todo)
-            raise TypeError("Cannot only use `eager_only` with polars.LazyFrame")
+            msg = "Cannot only use `eager_only` with polars.LazyFrame"
+            raise TypeError(msg)
         return LazyFrame(
             native_dataframe,
             is_polars=True,
@@ -283,7 +286,8 @@ def from_native(  # noqa: PLR0915
         )
     elif (pd := get_pandas()) is not None and isinstance(native_dataframe, pd.DataFrame):
         if series_only:  # pragma: no cover (todo)
-            raise TypeError("Cannot only use `series_only` with dataframe")
+            msg = "Cannot only use `series_only` with dataframe"
+            raise TypeError(msg)
         return DataFrame(
             PandasLikeDataFrame(
                 native_dataframe,
@@ -297,7 +301,8 @@ def from_native(  # noqa: PLR0915
         native_dataframe, mpd.DataFrame
     ):  # pragma: no cover
         if series_only:
-            raise TypeError("Cannot only use `series_only` with modin.DataFrame")
+            msg = "Cannot only use `series_only` with modin.DataFrame"
+            raise TypeError(msg)
         return DataFrame(
             PandasLikeDataFrame(
                 native_dataframe,
@@ -311,7 +316,8 @@ def from_native(  # noqa: PLR0915
         native_dataframe, cudf.DataFrame
     ):
         if series_only:
-            raise TypeError("Cannot only use `series_only` with modin.DataFrame")
+            msg = "Cannot only use `series_only` with modin.DataFrame"
+            raise TypeError(msg)
         return DataFrame(
             PandasLikeDataFrame(
                 native_dataframe,
@@ -323,7 +329,8 @@ def from_native(  # noqa: PLR0915
         )
     elif (pa := get_pyarrow()) is not None and isinstance(native_dataframe, pa.Table):
         if series_only:  # pragma: no cover (todo)
-            raise TypeError("Cannot only use `series_only` with arrow table")
+            msg = "Cannot only use `series_only` with arrow table"
+            raise TypeError(msg)
         return DataFrame(
             ArrowDataFrame(
                 native_dataframe, backend_version=parse_version(pa.__version__)
@@ -333,7 +340,8 @@ def from_native(  # noqa: PLR0915
         )
     elif hasattr(native_dataframe, "__narwhals_dataframe__"):  # pragma: no cover
         if series_only:  # pragma: no cover (todo)
-            raise TypeError("Cannot only use `series_only` with dataframe")
+            msg = "Cannot only use `series_only` with dataframe"
+            raise TypeError(msg)
         # placeholder (0,) version here, as we wouldn't use it in this case anyway.
         return DataFrame(
             native_dataframe.__narwhals_dataframe__(),
@@ -342,9 +350,11 @@ def from_native(  # noqa: PLR0915
         )
     elif hasattr(native_dataframe, "__narwhals_lazyframe__"):  # pragma: no cover
         if series_only:  # pragma: no cover (todo)
-            raise TypeError("Cannot only use `series_only` with lazyframe")
+            msg = "Cannot only use `series_only` with lazyframe"
+            raise TypeError(msg)
         if eager_only:  # pragma: no cover (todo)
-            raise TypeError("Cannot only use `eager_only` with lazyframe")
+            msg = "Cannot only use `eager_only` with lazyframe"
+            raise TypeError(msg)
         # placeholder (0,) version here, as we wouldn't use it in this case anyway.
         return LazyFrame(
             native_dataframe.__narwhals_lazyframe__(),
@@ -353,7 +363,8 @@ def from_native(  # noqa: PLR0915
         )
     elif (pl := get_polars()) is not None and isinstance(native_dataframe, pl.Series):
         if not allow_series:  # pragma: no cover (todo)
-            raise TypeError("Please set `allow_series=True`")
+            msg = "Please set `allow_series=True`"
+            raise TypeError(msg)
         return Series(
             native_dataframe,
             is_polars=True,
@@ -361,7 +372,8 @@ def from_native(  # noqa: PLR0915
         )
     elif (pd := get_pandas()) is not None and isinstance(native_dataframe, pd.Series):
         if not allow_series:  # pragma: no cover (todo)
-            raise TypeError("Please set `allow_series=True`")
+            msg = "Please set `allow_series=True`"
+            raise TypeError(msg)
         return Series(
             PandasLikeSeries(
                 native_dataframe,
@@ -375,7 +387,8 @@ def from_native(  # noqa: PLR0915
         native_dataframe, mpd.Series
     ):  # pragma: no cover
         if not allow_series:  # pragma: no cover (todo)
-            raise TypeError("Please set `allow_series=True`")
+            msg = "Please set `allow_series=True`"
+            raise TypeError(msg)
         return Series(
             PandasLikeSeries(
                 native_dataframe,
@@ -389,7 +402,8 @@ def from_native(  # noqa: PLR0915
         native_dataframe, cudf.Series
     ):  # pragma: no cover
         if not allow_series:  # pragma: no cover (todo)
-            raise TypeError("Please set `allow_series=True`")
+            msg = "Please set `allow_series=True`"
+            raise TypeError(msg)
         return Series(
             PandasLikeSeries(
                 native_dataframe,
@@ -403,7 +417,8 @@ def from_native(  # noqa: PLR0915
         native_dataframe, pa.ChunkedArray
     ):
         if not allow_series:  # pragma: no cover (todo)
-            raise TypeError("Please set `allow_series=True`")
+            msg = "Please set `allow_series=True`"
+            raise TypeError(msg)
         return Series(
             ArrowSeries(
                 native_dataframe, backend_version=parse_version(pa.__version__), name=""
@@ -413,7 +428,8 @@ def from_native(  # noqa: PLR0915
         )
     elif hasattr(native_dataframe, "__narwhals_series__"):  # pragma: no cover
         if not allow_series:  # pragma: no cover (todo)
-            raise TypeError("Please set `allow_series=True`")
+            msg = "Please set `allow_series=True`"
+            raise TypeError(msg)
         # placeholder (0,) version here, as we wouldn't use it in this case anyway.
         return Series(
             native_dataframe.__narwhals_series__(), backend_version=(0,), is_polars=False
diff --git a/narwhals/utils.py b/narwhals/utils.py
index 0f4baec3..7f65d8bc 100644
--- a/narwhals/utils.py
+++ b/narwhals/utils.py
@@ -138,7 +138,8 @@ def maybe_align_index(lhs: T, rhs: Series | BaseFrame[Any]) -> T:
 
     def _validate_index(index: Any) -> None:
         if not index.is_unique:
-            raise ValueError("given index doesn't have a unique index")
+            msg = "given index doesn't have a unique index"
+            raise ValueError(msg)
 
     lhs_any = cast(Any, lhs)
     rhs_any = cast(Any, rhs)
diff --git a/pyproject.toml b/pyproject.toml
index 0e9fdd4b..29c9d2b7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "narwhals"
-version = "1.0.4"
+version = "1.0.5"
 authors = [
   { name="Marco Gorelli", email="33491632+MarcoGorelli@users.noreply.github.com" },
 ]
diff --git a/utils/bump_version.py b/utils/bump_version.py
index 3a0208d3..e1bbf8d2 100644
--- a/utils/bump_version.py
+++ b/utils/bump_version.py
@@ -4,6 +4,9 @@ import re
 import subprocess
 import sys
 
+subprocess.run(["git", "fetch", "upstream"])
+subprocess.run(["git", "reset", "--hard", "upstream/main"])
+
 how = sys.argv[1]
 
 with open("pyproject.toml", encoding="utf-8") as f:
@@ -39,5 +42,5 @@ with open("docs/installation.md", "w", encoding="utf-8") as f:
     f.write(content)
 
 subprocess.run(["git", "commit", "-a", "-m", f"Bump version to {version}"])
-subprocess.run(["git", "tag", "-a", version, "-m", version])
+subprocess.run(["git", "tag", "-a", f"v{version}", "-m", f"v{version}"])
 subprocess.run(["git", "push", "upstream", "HEAD", "--follow-tags"])
diff --git a/utils/check_backend_completeness.py b/utils/check_backend_completeness.py
index abed808c..6c9cabcb 100644
--- a/utils/check_backend_completeness.py
+++ b/utils/check_backend_completeness.py
@@ -14,24 +14,20 @@ from narwhals._arrow.dataframe import ArrowDataFrame
 MISSING = [
     "DataFrame.filter",
     "DataFrame.group_by",
-    "DataFrame.head",
     "DataFrame.is_duplicated",
     "DataFrame.is_empty",
     "DataFrame.is_unique",
     "DataFrame.item",
     "DataFrame.iter_rows",
     "DataFrame.join",
-    "DataFrame.null_count",
     "DataFrame.pipe",
     "DataFrame.rename",
-    "DataFrame.tail",
     "DataFrame.unique",
     "DataFrame.write_parquet",
     "Series.drop_nulls",
     "Series.fill_null",
     "Series.filter",
     "Series.from_iterable",
-    "Series.head",
     "Series.is_between",
     "Series.is_duplicated",
     "Series.is_first_distinct",
@@ -42,17 +38,13 @@ MISSING = [
     "Series.is_unique",
     "Series.item",
     "Series.len",
-    "Series.max",
-    "Series.min",
     "Series.n_unique",
-    "Series.null_count",
     "Series.quantile",
     "Series.round",
     "Series.sample",
     "Series.shift",
     "Series.sort",
     "Series.sum",
-    "Series.tail",
     "Series.to_frame",
     "Series.to_pandas",
     "Series.unique",


Test patch:
diff --git a/tests/expr/min_max_test.py b/tests/expr/min_max_test.py
new file mode 100644
index 00000000..1d62e199
--- /dev/null
+++ b/tests/expr/min_max_test.py
@@ -0,0 +1,17 @@
+from __future__ import annotations
+
+from typing import Any
+
+import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
+
+
+def test_expr_min_max(constructor_with_pyarrow: Any) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    df = nw.from_native(constructor_with_pyarrow(data), eager_only=True)
+    result_min = df.select(nw.col("a", "b", "z").min())
+    result_max = df.select(nw.col("a", "b", "z").max())
+    expected_min = {"a": [1], "b": [4], "z": [7.0]}
+    expected_max = {"a": [3], "b": [6], "z": [9]}
+    compare_dicts(result_min, expected_min)
+    compare_dicts(result_max, expected_max)
diff --git a/tests/expr/null_count_test.py b/tests/expr/null_count_test.py
index 5461db86..e5a02e26 100644
--- a/tests/expr/null_count_test.py
+++ b/tests/expr/null_count_test.py
@@ -9,8 +9,8 @@ data = {
 }
 
 
-def test_null_count(constructor: Any) -> None:
-    df = nw.from_native(constructor(data), eager_only=True)
+def test_null_count(constructor_with_pyarrow: Any) -> None:
+    df = nw.from_native(constructor_with_pyarrow(data), eager_only=True)
     result = df.select(nw.all().null_count())
     expected = {
         "a": [2],
diff --git a/tests/frame/head_test.py b/tests/frame/head_test.py
index ef61334b..9b87e579 100644
--- a/tests/frame/head_test.py
+++ b/tests/frame/head_test.py
@@ -6,16 +6,21 @@ import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 
-def test_head(constructor_with_lazy: Any) -> None:
+def test_head(constructor_with_pyarrow: Any) -> None:
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
-    df_raw = constructor_with_lazy(data)
-    df = nw.from_native(df_raw).lazy()
-    result = nw.to_native(df.head(2))
     expected = {"a": [1, 3], "b": [4, 4], "z": [7.0, 8.0]}
+
+    df_raw = constructor_with_pyarrow(data)
+    df = nw.from_native(df_raw).lazy()
+
+    result = df.head(2)
     compare_dicts(result, expected)
-    result = nw.to_native(df.collect().head(2))
-    expected = {"a": [1, 3], "b": [4, 4], "z": [7.0, 8.0]}
+
+    result = df.collect().head(2)  # type: ignore[assignment]
     compare_dicts(result, expected)
-    result = nw.to_native(df.collect().select(nw.col("a").head(2)))
-    expected = {"a": [1, 3]}
+
+    result = df.collect().head(-1)  # type: ignore[assignment]
     compare_dicts(result, expected)
+
+    result = df.collect().select(nw.col("a").head(2))  # type: ignore[assignment]
+    compare_dicts(result, {"a": expected["a"]})
diff --git a/tests/frame/null_count_test.py b/tests/frame/null_count_test.py
index 4e12dc61..5fc66daf 100644
--- a/tests/frame/null_count_test.py
+++ b/tests/frame/null_count_test.py
@@ -6,10 +6,10 @@ import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 
-def test_null_count(constructor: Any) -> None:
+def test_null_count(constructor_with_pyarrow: Any) -> None:
     data = {"a": [None, 3, 2], "b": [4, 4, 6], "z": [7.0, None, 9]}
-    df_raw = constructor(data)
+    df_raw = constructor_with_pyarrow(data)
     df = nw.from_native(df_raw, eager_only=True)
-    result = nw.to_native(df.null_count())
+    result = df.null_count()
     expected = {"a": [1], "b": [0], "z": [1]}
     compare_dicts(result, expected)
diff --git a/tests/frame/tail_test.py b/tests/frame/tail_test.py
index 768b1969..28f9edf3 100644
--- a/tests/frame/tail_test.py
+++ b/tests/frame/tail_test.py
@@ -6,16 +6,21 @@ import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 
-def test_tail(constructor_with_lazy: Any) -> None:
+def test_tail(constructor_with_pyarrow: Any) -> None:
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
-    df_raw = constructor_with_lazy(data)
-    df = nw.from_native(df_raw).lazy()
-    result = nw.to_native(df.tail(2))
     expected = {"a": [3, 2], "b": [4, 6], "z": [8.0, 9]}
+
+    df_raw = constructor_with_pyarrow(data)
+    df = nw.from_native(df_raw).lazy()
+
+    result = df.tail(2)
     compare_dicts(result, expected)
-    result = nw.to_native(df.collect().tail(2))
-    expected = {"a": [3, 2], "b": [4, 6], "z": [8.0, 9]}
+
+    result = df.collect().tail(2)  # type: ignore[assignment]
     compare_dicts(result, expected)
-    result = nw.to_native(df.collect().select(nw.col("a").tail(2)))
-    expected = {"a": [3, 2]}
+
+    result = df.collect().tail(-1)  # type: ignore[assignment]
     compare_dicts(result, expected)
+
+    result = df.collect().select(nw.col("a").tail(2))  # type: ignore[assignment]
+    compare_dicts(result, {"a": expected["a"]})
diff --git a/tests/series/head_test.py b/tests/series/head_test.py
new file mode 100644
index 00000000..3418bd92
--- /dev/null
+++ b/tests/series/head_test.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+import narwhals as nw
+
+
+@pytest.mark.parametrize("n", [2, -1])
+def test_head(constructor_series_with_pyarrow: Any, n: int) -> None:
+    s = nw.from_native(constructor_series_with_pyarrow([1, 2, 3]), series_only=True)
+
+    assert s.head(n).to_list() == [1, 2]
diff --git a/tests/series/null_count_test.py b/tests/series/null_count_test.py
new file mode 100644
index 00000000..b2bf84bd
--- /dev/null
+++ b/tests/series/null_count_test.py
@@ -0,0 +1,12 @@
+from __future__ import annotations
+
+from typing import Any
+
+import narwhals as nw
+
+
+def test_null_count(constructor_series_with_pyarrow: Any) -> None:
+    data = [1, 2, None]
+    series = nw.from_native(constructor_series_with_pyarrow(data), series_only=True)
+    result = series.null_count()
+    assert result == 1
diff --git a/tests/series/tail_test.py b/tests/series/tail_test.py
new file mode 100644
index 00000000..21759321
--- /dev/null
+++ b/tests/series/tail_test.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+import narwhals as nw
+
+
+@pytest.mark.parametrize("n", [2, -1])
+def test_tail(constructor_series_with_pyarrow: Any, n: int) -> None:
+    s = nw.from_native(constructor_series_with_pyarrow([1, 2, 3]), series_only=True)
+
+    assert s.tail(n).to_list() == [2, 3]
diff --git a/tests/series/test_common.py b/tests/series/test_common.py
index c69f852e..2a573958 100644
--- a/tests/series/test_common.py
+++ b/tests/series/test_common.py
@@ -172,13 +172,6 @@ def test_is_unique(df_raw: Any) -> None:
     assert (result.to_numpy() == expected).all()
 
 
-@pytest.mark.parametrize("s_raw", [pd.Series([1, 2, None]), pl.Series([1, 2, None])])
-def test_null_count(s_raw: Any) -> None:
-    series = nw.from_native(s_raw, series_only=True)
-    result = series.null_count()
-    assert result == 1
-
-
 @pytest.mark.parametrize("df_raw", [df_pandas, df_polars])
 def test_is_first_distinct(df_raw: Any) -> None:
     series = nw.from_native(df_raw["b"], series_only=True)
@@ -299,21 +292,3 @@ def test_item(df_raw: Any, index: int, expected: int) -> None:
         match=re.escape("can only call '.item()' if the Series is of length 1,"),
     ):
         s.item(None)
-
-
-@pytest.mark.parametrize("df_raw", [df_pandas, df_polars])
-@pytest.mark.parametrize("n", [1, 2, 3, 10])
-def test_head(df_raw: Any, n: int) -> None:
-    s_raw = df_raw["z"]
-    s = nw.from_native(s_raw, allow_series=True)
-
-    assert s.head(n) == nw.from_native(s_raw.head(n), series_only=True)
-
-
-@pytest.mark.parametrize("df_raw", [df_pandas, df_polars])
-@pytest.mark.parametrize("n", [1, 2, 3, 10])
-def test_tail(df_raw: Any, n: int) -> None:
-    s_raw = df_raw["z"]
-    s = nw.from_native(s_raw, allow_series=True)
-
-    assert s.tail(n) == nw.from_native(s_raw.tail(n), series_only=True)
diff --git a/tests/utils.py b/tests/utils.py
index 202c332e..d9984769 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -14,9 +14,8 @@ if TYPE_CHECKING:
 
 def zip_strict(left: Sequence[Any], right: Sequence[Any]) -> Iterator[Any]:
     if len(left) != len(right):
-        raise ValueError(
-            "left len != right len", len(left), len(right)
-        )  # pragma: no cover
+        msg = f"left {len(left)=} != right {len(right)=}"  # pragma: no cover
+        raise ValueError(msg)  # pragma: no cover
     return zip(left, right)