Repository: narwhals-dev/narwhals
Pull request #855 (2024-08-23 16:01:55)

feat: make DataFrame.select stricter: non-string columns can only be selected using `nw.col`
Quickly reverting https://github.com/narwhals-dev/narwhals/pull/828 before anyone uses it 🙈 

Parsing non-strings as expressions creates other problems, which I noticed while reviewing the when-then-otherwise PR

If people want to select columns which might not be strings, then can use `nw.col` and be explicit 😇 

<!--
# 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.




Patch:
diff --git a/.gitignore b/.gitignore
index a36db14e..d6edf57e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@ todo.md
 site/
 .coverage.*
 .nox
+*.lock
 
 docs/api-completeness/*.md
 !docs/api-completeness/index.md
\ No newline at end of file
diff --git a/docs/api-reference/dataframe.md b/docs/api-reference/dataframe.md
index 08ad0f7c..c144b4af 100644
--- a/docs/api-reference/dataframe.md
+++ b/docs/api-reference/dataframe.md
@@ -26,6 +26,7 @@
         - null_count
         - pipe
         - rename
+        - row
         - rows
         - schema
         - select
diff --git a/docs/api-reference/dependencies.md b/docs/api-reference/dependencies.md
index 7e44ae9c..6c1a93d9 100644
--- a/docs/api-reference/dependencies.md
+++ b/docs/api-reference/dependencies.md
@@ -16,6 +16,8 @@
         - is_modin_series
         - is_numpy_array
         - is_pandas_dataframe
+        - is_pandas_like_dataframe
+        - is_pandas_like_series
         - is_pandas_series
         - is_polars_dataframe
         - is_polars_lazyframe
diff --git a/docs/installation.md b/docs/installation.md
index 3e36e339..65c8bb55 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.5.3'
+'1.5.4'
 ```
 then installation worked correctly!
diff --git a/docs/other/column_names.md b/docs/other/column_names.md
index 97767053..14346a30 100644
--- a/docs/other/column_names.md
+++ b/docs/other/column_names.md
@@ -16,7 +16,7 @@ create one with _two_ such columns!
 What does Narwhals do about this?
 
 - In general, non-string column names are supported. In some places where this might
-  create ambiguity (such as `DataFrame.__getitem__`) we may be strict and only
+  create ambiguity (such as `DataFrame.__getitem__` or `DataFrame.select`) we may be strict and only
   allow passing in column names if they're strings.
 - If you have a use-case that's
   failing for non-string column names, please report it to [https://github.com/narwhals-dev/narwhals/issues](https://github.com/narwhals-dev/narwhals/issues)
diff --git a/narwhals/__init__.py b/narwhals/__init__.py
index 70345995..2c43ccde 100644
--- a/narwhals/__init__.py
+++ b/narwhals/__init__.py
@@ -1,3 +1,4 @@
+from narwhals import dependencies
 from narwhals import selectors
 from narwhals import stable
 from narwhals.dataframe import DataFrame
@@ -22,11 +23,11 @@ from narwhals.dtypes import UInt32
 from narwhals.dtypes import UInt64
 from narwhals.dtypes import Unknown
 from narwhals.expr import Expr
-from narwhals.expr import all
+from narwhals.expr import all_ as all
 from narwhals.expr import all_horizontal
 from narwhals.expr import any_horizontal
 from narwhals.expr import col
-from narwhals.expr import len
+from narwhals.expr import len_ as len
 from narwhals.expr import lit
 from narwhals.expr import max
 from narwhals.expr import mean
@@ -50,9 +51,10 @@ from narwhals.utils import maybe_align_index
 from narwhals.utils import maybe_convert_dtypes
 from narwhals.utils import maybe_set_index
 
-__version__ = "1.5.3"
+__version__ = "1.5.4"
 
 __all__ = [
+    "dependencies",
     "selectors",
     "concat",
     "from_dict",
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index 65a155e0..20e50716 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -63,6 +63,9 @@ class ArrowDataFrame:
     def __len__(self) -> int:
         return len(self._native_frame)
 
+    def row(self, index: int) -> tuple[Any, ...]:
+        return tuple(col[index] for col in self._native_frame)
+
     def rows(
         self, *, named: bool = False
     ) -> list[tuple[Any, ...]] | list[dict[str, Any]]:
@@ -377,11 +380,18 @@ class ArrowDataFrame:
         self,
         *predicates: IntoArrowExpr,
     ) -> Self:
-        plx = self.__narwhals_namespace__()
-        expr = plx.all_horizontal(*predicates)
-        # Safety: all_horizontal's expression only returns a single column.
-        mask = expr._call(self)[0]
-        return self._from_native_frame(self._native_frame.filter(mask._native_series))
+        if (
+            len(predicates) == 1
+            and isinstance(predicates[0], list)
+            and all(isinstance(x, bool) for x in predicates[0])
+        ):
+            mask = predicates[0]
+        else:
+            plx = self.__narwhals_namespace__()
+            expr = plx.all_horizontal(*predicates)
+            # Safety: all_horizontal's expression only returns a single column.
+            mask = expr._call(self)[0]._native_series
+        return self._from_native_frame(self._native_frame.filter(mask))
 
     def null_count(self) -> Self:
         import pyarrow as pa  # ignore-banned-import()
diff --git a/narwhals/_arrow/namespace.py b/narwhals/_arrow/namespace.py
index a17a6a62..9bb4be58 100644
--- a/narwhals/_arrow/namespace.py
+++ b/narwhals/_arrow/namespace.py
@@ -173,7 +173,10 @@ class ArrowNamespace:
         return reduce(lambda x, y: x | y, parse_into_exprs(*exprs, namespace=self))
 
     def sum_horizontal(self, *exprs: IntoArrowExpr) -> ArrowExpr:
-        return reduce(lambda x, y: x + y, parse_into_exprs(*exprs, namespace=self))
+        return reduce(
+            lambda x, y: x + y,
+            [expr.fill_null(0) for expr in parse_into_exprs(*exprs, namespace=self)],
+        )
 
     def mean_horizontal(self, *exprs: IntoArrowExpr) -> IntoArrowExpr:
         arrow_exprs = parse_into_exprs(*exprs, namespace=self)
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index fb15f3aa..39394924 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -233,7 +233,8 @@ class ArrowSeries:
         return len(self._native_series)
 
     def filter(self, other: Any) -> Self:
-        other = validate_column_comparand(other)
+        if not (isinstance(other, list) and all(isinstance(x, bool) for x in other)):
+            other = validate_column_comparand(other)
         return self._from_native_series(self._native_series.filter(other))
 
     def mean(self) -> int:
diff --git a/narwhals/_dask/dataframe.py b/narwhals/_dask/dataframe.py
index 35ef28bb..9774d6c8 100644
--- a/narwhals/_dask/dataframe.py
+++ b/narwhals/_dask/dataframe.py
@@ -72,12 +72,19 @@ class DaskLazyFrame:
         self,
         *predicates: DaskExpr,
     ) -> Self:
-        from narwhals._dask.namespace import DaskNamespace
+        if (
+            len(predicates) == 1
+            and isinstance(predicates[0], list)
+            and all(isinstance(x, bool) for x in predicates[0])
+        ):
+            mask = predicates[0]
+        else:
+            from narwhals._dask.namespace import DaskNamespace
 
-        plx = DaskNamespace(backend_version=self._backend_version)
-        expr = plx.all_horizontal(*predicates)
-        # Safety: all_horizontal's expression only returns a single column.
-        mask = expr._call(self)[0]
+            plx = DaskNamespace(backend_version=self._backend_version)
+            expr = plx.all_horizontal(*predicates)
+            # Safety: all_horizontal's expression only returns a single column.
+            mask = expr._call(self)[0]
         return self._from_native_frame(self._native_frame.loc[mask])
 
     def lazy(self) -> Self:
diff --git a/narwhals/_dask/namespace.py b/narwhals/_dask/namespace.py
index 868cc20d..71fd4b8b 100644
--- a/narwhals/_dask/namespace.py
+++ b/narwhals/_dask/namespace.py
@@ -133,7 +133,10 @@ class DaskNamespace:
         return reduce(lambda x, y: x | y, parse_into_exprs(*exprs, namespace=self))
 
     def sum_horizontal(self, *exprs: IntoDaskExpr) -> DaskExpr:
-        return reduce(lambda x, y: x + y, parse_into_exprs(*exprs, namespace=self))
+        return reduce(
+            lambda x, y: x + y,
+            [expr.fill_null(0) for expr in parse_into_exprs(*exprs, namespace=self)],
+        )
 
     def mean_horizontal(self, *exprs: IntoDaskExpr) -> IntoDaskExpr:
         dask_exprs = parse_into_exprs(*exprs, namespace=self)
diff --git a/narwhals/_expression_parsing.py b/narwhals/_expression_parsing.py
index 06383821..a74ca3c6 100644
--- a/narwhals/_expression_parsing.py
+++ b/narwhals/_expression_parsing.py
@@ -3,7 +3,6 @@
 # and pandas or PyArrow.
 from __future__ import annotations
 
-from collections.abc import Hashable
 from copy import copy
 from typing import TYPE_CHECKING
 from typing import Any
@@ -187,14 +186,18 @@ def parse_into_expr(
         return into_expr  # type: ignore[return-value]
     if hasattr(into_expr, "__narwhals_series__"):
         return namespace._create_expr_from_series(into_expr)  # type: ignore[arg-type]
-    if isinstance(into_expr, Hashable):
-        # pandas allows non-string column names, so we pass them through here.
-        return namespace.col(into_expr)  # type: ignore[arg-type]
+    if isinstance(into_expr, str):
+        return namespace.col(into_expr)
     if is_numpy_array(into_expr):
         series = namespace._create_compliant_series(into_expr)
-        return namespace._create_expr_from_series(series)
-    msg = f"Expected IntoExpr, got {type(into_expr)}"  # pragma: no cover
-    raise AssertionError(msg)
+        return namespace._create_expr_from_series(series)  # type: ignore[arg-type]
+    msg = (
+        f"Expected an object which can be converted into an expression, got {type(into_expr)}\n\n"  # pragma: no cover
+        "Hint: if you were trying to select a column which does not have a string column name, then "
+        "you should explicitly use `nw.col`.\nFor example, `df.select(nw.col(0))` if you have a column "
+        "named `0`."
+    )
+    raise TypeError(msg)
 
 
 def reuse_series_implementation(
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index c6a51be2..193955cb 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -283,15 +283,25 @@ class PandasLikeDataFrame:
             )
         )
 
+    def row(self, row: int) -> tuple[Any, ...]:
+        return tuple(x for x in self._native_frame.iloc[row])
+
     def filter(
         self,
         *predicates: IntoPandasLikeExpr,
     ) -> Self:
         plx = self.__narwhals_namespace__()
-        expr = plx.all_horizontal(*predicates)
-        # Safety: all_horizontal's expression only returns a single column.
-        mask = expr._call(self)[0]
-        _mask = validate_dataframe_comparand(self._native_frame.index, mask)
+        if (
+            len(predicates) == 1
+            and isinstance(predicates[0], list)
+            and all(isinstance(x, bool) for x in predicates[0])
+        ):
+            _mask = predicates[0]
+        else:
+            expr = plx.all_horizontal(*predicates)
+            # Safety: all_horizontal's expression only returns a single column.
+            mask = expr._call(self)[0]
+            _mask = validate_dataframe_comparand(self._native_frame.index, mask)
         return self._from_native_frame(self._native_frame.loc[_mask])
 
     def with_columns(
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index 193b1786..44154453 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -227,8 +227,8 @@ class PandasLikeExpr:
 
     def filter(self, *predicates: Any) -> Self:
         plx = self.__narwhals_namespace__()
-        expr = plx.all_horizontal(*predicates)
-        return reuse_series_implementation(self, "filter", other=expr)
+        other = plx.all_horizontal(*predicates)
+        return reuse_series_implementation(self, "filter", other=other)
 
     def drop_nulls(self) -> Self:
         return reuse_series_implementation(self, "drop_nulls")
diff --git a/narwhals/_pandas_like/namespace.py b/narwhals/_pandas_like/namespace.py
index 3c378e3c..fcaf849e 100644
--- a/narwhals/_pandas_like/namespace.py
+++ b/narwhals/_pandas_like/namespace.py
@@ -202,7 +202,10 @@ class PandasLikeNamespace:
 
     # --- horizontal ---
     def sum_horizontal(self, *exprs: IntoPandasLikeExpr) -> PandasLikeExpr:
-        return reduce(lambda x, y: x + y, parse_into_exprs(*exprs, namespace=self))
+        return reduce(
+            lambda x, y: x + y,
+            [expr.fill_null(0) for expr in parse_into_exprs(*exprs, namespace=self)],
+        )
 
     def all_horizontal(self, *exprs: IntoPandasLikeExpr) -> PandasLikeExpr:
         return reduce(lambda x, y: x & y, parse_into_exprs(*exprs, namespace=self))
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index 90434ebd..9532c39c 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -229,7 +229,8 @@ class PandasLikeSeries:
 
     def filter(self, other: Any) -> PandasLikeSeries:
         ser = self._native_series
-        other = validate_column_comparand(self._native_series.index, other)
+        if not (isinstance(other, list) and all(isinstance(x, bool) for x in other)):
+            other = validate_column_comparand(self._native_series.index, other)
         return self._from_native_series(self._rename(ser.loc[other], ser.name))
 
     def __eq__(self, other: object) -> PandasLikeSeries:  # type: ignore[override]
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index b09f12cc..64df5913 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -158,8 +158,13 @@ class BaseFrame(Generic[FrameT]):
             )
         )
 
-    def filter(self, *predicates: IntoExpr | Iterable[IntoExpr]) -> Self:
-        predicates, _ = self._flatten_and_extract(*predicates)
+    def filter(self, *predicates: IntoExpr | Iterable[IntoExpr] | list[bool]) -> Self:
+        if not (
+            len(predicates) == 1
+            and isinstance(predicates[0], list)
+            and all(isinstance(x, bool) for x in predicates[0])
+        ):
+            predicates, _ = self._flatten_and_extract(*predicates)
         return self._from_compliant_dataframe(
             self._compliant_frame.filter(*predicates),
         )
@@ -599,6 +604,8 @@ class DataFrame(BaseFrame[FrameT]):
                 2
             ]
         """
+        if isinstance(item, int):
+            item = [item]
         if (
             isinstance(item, tuple)
             and len(item) == 2
@@ -693,6 +700,40 @@ class DataFrame(BaseFrame[FrameT]):
             }
         return self._compliant_frame.to_dict(as_series=as_series)  # type: ignore[no-any-return]
 
+    def row(self, index: int) -> tuple[Any, ...]:
+        """
+        Get values at given row.
+
+        !!!note
+            You should NEVER use this method to iterate over a DataFrame;
+            if you require row-iteration you should strongly prefer use of iter_rows() instead.
+
+        Arguments:
+            index: Row number.
+
+        Examples:
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> data = {"a": [1, 2, 3], "b": [4, 5, 6]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+
+            Let's define a library-agnostic function to get the second row.
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.row(1)
+
+            We can then pass pandas / Polars / any other supported library:
+
+            >>> func(df_pd)
+            (2, 5)
+            >>> func(df_pl)
+            (2, 5)
+        """
+        return self._compliant_frame.row(index)  # type: ignore[no-any-return]
+
     # inherited
     def pipe(self, function: Callable[[Any], Self], *args: Any, **kwargs: Any) -> Self:
         """
@@ -1483,14 +1524,15 @@ class DataFrame(BaseFrame[FrameT]):
         """
         return super().unique(subset, keep=keep, maintain_order=maintain_order)
 
-    def filter(self, *predicates: IntoExpr | Iterable[IntoExpr]) -> Self:
+    def filter(self, *predicates: IntoExpr | Iterable[IntoExpr] | list[bool]) -> Self:
         r"""
         Filter the rows in the DataFrame based on one or more predicate expressions.
 
         The original order of the remaining rows is preserved.
 
         Arguments:
-            predicates: Expression(s) that evaluates to a boolean Series.
+            *predicates: Expression(s) that evaluates to a boolean Series. Can
+                also be a (single!) boolean list.
 
         Examples:
             >>> import pandas as pd
@@ -2493,11 +2535,15 @@ class LazyFrame(BaseFrame[FrameT]):
 
         Arguments:
             *exprs: Column(s) to select, specified as positional arguments.
-                     Accepts expression input. Strings are parsed as column names,
-                     other non-expression inputs are parsed as literals.
-
+                Accepts expression input. Strings are parsed as column names.
             **named_exprs: Additional columns to select, specified as keyword arguments.
-                            The columns will be renamed to the keyword used.
+                The columns will be renamed to the keyword used.
+
+        Notes:
+            If you'd like to select a column whose name isn't a string (for example,
+            if you're working with pandas) then you should explicitly use `nw.col` instead
+            of just passing the column name. For example, to select a column named
+            `0` use `df.select(nw.col(0))`, not `df.select(0)`.
 
         Examples:
             >>> import pandas as pd
@@ -2949,14 +2995,15 @@ class LazyFrame(BaseFrame[FrameT]):
         """
         return super().unique(subset, keep=keep, maintain_order=maintain_order)
 
-    def filter(self, *predicates: IntoExpr | Iterable[IntoExpr]) -> Self:
+    def filter(self, *predicates: IntoExpr | Iterable[IntoExpr] | list[bool]) -> Self:
         r"""
         Filter the rows in the LazyFrame based on a predicate expression.
 
         The original order of the remaining rows is preserved.
 
         Arguments:
-            *predicates: Expression that evaluates to a boolean Series.
+            *predicates: Expression that evaluates to a boolean Series. Can
+                also be a (single!) boolean list.
 
         Examples:
             >>> import pandas as pd
diff --git a/narwhals/dependencies.py b/narwhals/dependencies.py
index 7ed2f021..66516eac 100644
--- a/narwhals/dependencies.py
+++ b/narwhals/dependencies.py
@@ -134,6 +134,24 @@ def is_numpy_array(arr: Any) -> TypeGuard[np.ndarray]:
     return bool((np := get_numpy()) is not None and isinstance(arr, np.ndarray))
 
 
+def is_pandas_like_dataframe(df: Any) -> bool:
+    """
+    Check whether `df` is a pandas-like DataFrame without doing any imports
+
+    By "pandas-like", we mean: pandas, Modin, cuDF.
+    """
+    return is_pandas_dataframe(df) or is_modin_dataframe(df) or is_cudf_dataframe(df)
+
+
+def is_pandas_like_series(arr: Any) -> bool:
+    """
+    Check whether `arr` is a pandas-like Series without doing any imports
+
+    By "pandas-like", we mean: pandas, Modin, cuDF.
+    """
+    return is_pandas_series(arr) or is_modin_series(arr) or is_cudf_series(arr)
+
+
 __all__ = [
     "get_polars",
     "get_pandas",
@@ -154,4 +172,6 @@ __all__ = [
     "is_pyarrow_chunked_array",
     "is_numpy_array",
     "is_dask_dataframe",
+    "is_pandas_like_dataframe",
+    "is_pandas_like_series",
 ]
diff --git a/narwhals/expr.py b/narwhals/expr.py
index 74ead79e..9d8e5386 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -3657,7 +3657,8 @@ def col(*names: str | Iterable[str]) -> Expr:
     return Expr(func)
 
 
-def all() -> Expr:
+# Add underscore so it doesn't conflict with builtin `all`
+def all_() -> Expr:
     """
     Instantiate an expression representing all columns.
 
@@ -3696,7 +3697,8 @@ def all() -> Expr:
     return Expr(lambda plx: plx.all())
 
 
-def len() -> Expr:
+# Add underscore so it doesn't conflict with builtin `len`
+def len_() -> Expr:
     """
     Return the number of rows.
 
@@ -3903,17 +3905,22 @@ def max(*columns: str) -> Expr:
 
 def sum_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
     """
-    Sum all values horizontally across columns
+    Sum all values horizontally across columns.
+
+    Warning:
+        Unlike Polars, we support horizontal sum over numeric columns only.
 
     Arguments:
-        exprs: Name(s) of the columns to use in the aggregation function. Accepts expression input.
+        exprs: Name(s) of the columns to use in the aggregation function. Accepts
+            expression input.
 
     Examples:
         >>> import pandas as pd
         >>> import polars as pl
         >>> import narwhals as nw
-        >>> df_pl = pl.DataFrame({"a": [1, 2, 3], "b": [5, 10, 15]})
-        >>> df_pd = pd.DataFrame({"a": [1, 2, 3], "b": [5, 10, 15]})
+        >>> data = {"a": [1, 2, 3], "b": [5, 10, None]}
+        >>> df_pl = pl.DataFrame(data)
+        >>> df_pd = pd.DataFrame(data)
 
         We define a dataframe-agnostic function:
 
@@ -3924,10 +3931,10 @@ def sum_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
         We can then pass either pandas or polars to `func`:
 
         >>> func(df_pd)
-            a
-        0   6
-        1  12
-        2  18
+              a
+        0   6.0
+        1  12.0
+        2   3.0
         >>> func(df_pl)
         shape: (3, 1)
         ┌─────┐
@@ -3937,7 +3944,7 @@ def sum_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
         ╞═════╡
         │ 6   │
         │ 12  │
-        │ 18  │
+        │ 3   │
         └─────┘
     """
     return Expr(
diff --git a/narwhals/stable/v1.py b/narwhals/stable/v1.py
index 961fff16..a11df24c 100644
--- a/narwhals/stable/v1.py
+++ b/narwhals/stable/v1.py
@@ -10,6 +10,7 @@ from typing import TypeVar
 from typing import overload
 
 import narwhals as nw
+from narwhals import dependencies
 from narwhals import selectors
 from narwhals.dataframe import DataFrame as NwDataFrame
 from narwhals.dataframe import LazyFrame as NwLazyFrame
@@ -1127,17 +1128,22 @@ def sum(*columns: str) -> Expr:
 
 def sum_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
     """
-    Sum all values horizontally across columns
+    Sum all values horizontally across columns.
+
+    Warning:
+        Unlike Polars, we support horizontal sum over numeric columns only.
 
     Arguments:
-        exprs: Name(s) of the columns to use in the aggregation function. Accepts expression input.
+        exprs: Name(s) of the columns to use in the aggregation function. Accepts
+            expression input.
 
     Examples:
         >>> import pandas as pd
         >>> import polars as pl
         >>> import narwhals.stable.v1 as nw
-        >>> df_pl = pl.DataFrame({"a": [1, 2, 3], "b": [5, 10, 15]})
-        >>> df_pd = pd.DataFrame({"a": [1, 2, 3], "b": [5, 10, 15]})
+        >>> data = {"a": [1, 2, 3], "b": [5, 10, None]}
+        >>> df_pl = pl.DataFrame(data)
+        >>> df_pd = pd.DataFrame(data)
 
         We define a dataframe-agnostic function:
 
@@ -1148,10 +1154,10 @@ def sum_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
         We can then pass either pandas or polars to `func`:
 
         >>> func(df_pd)
-            a
-        0   6
-        1  12
-        2  18
+              a
+        0   6.0
+        1  12.0
+        2   3.0
         >>> func(df_pl)
         shape: (3, 1)
         ┌─────┐
@@ -1161,7 +1167,7 @@ def sum_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
         ╞═════╡
         │ 6   │
         │ 12  │
-        │ 18  │
+        │ 3   │
         └─────┘
     """
     return _stableify(nw.sum_horizontal(*exprs))
@@ -1400,6 +1406,11 @@ def maybe_convert_dtypes(df: T, *args: bool, **kwargs: bool | str) -> T:
     """
     Convert columns to the best possible dtypes using dtypes supporting ``pd.NA``, if df is pandas-like.
 
+    Arguments:
+        obj: DataFrame or Series.
+        *args: Additional arguments which gets passed through.
+        **kwargs: Additional arguments which gets passed through.
+
     Notes:
         For non-pandas-like inputs, this is a no-op.
         Also, `args` and `kwargs` just get passed down to the underlying library as-is.
@@ -1592,6 +1603,7 @@ def from_dict(
 __all__ = [
     "selectors",
     "concat",
+    "dependencies",
     "to_native",
     "from_native",
     "is_ordered_categorical",
diff --git a/narwhals/utils.py b/narwhals/utils.py
index cc2a482c..4142675e 100644
--- a/narwhals/utils.py
+++ b/narwhals/utils.py
@@ -22,6 +22,8 @@ from narwhals.dependencies import get_pyarrow
 from narwhals.dependencies import is_cudf_series
 from narwhals.dependencies import is_modin_series
 from narwhals.dependencies import is_pandas_dataframe
+from narwhals.dependencies import is_pandas_like_dataframe
+from narwhals.dependencies import is_pandas_like_series
 from narwhals.dependencies import is_pandas_series
 from narwhals.dependencies import is_polars_series
 from narwhals.dependencies import is_pyarrow_chunked_array
@@ -261,22 +263,26 @@ def maybe_set_index(df: T, column_names: str | list[str]) -> T:
         4  1
         5  2
     """
-    from narwhals._pandas_like.dataframe import PandasLikeDataFrame
-
     df_any = cast(Any, df)
-    if isinstance(getattr(df_any, "_compliant_frame", None), PandasLikeDataFrame):
+    native_frame = to_native(df_any)
+    if is_pandas_like_dataframe(native_frame):
         return df_any._from_compliant_dataframe(  # type: ignore[no-any-return]
             df_any._compliant_frame._from_native_frame(
-                df_any._compliant_frame._native_frame.set_index(column_names)
+                native_frame.set_index(column_names)
             )
         )
-    return df
+    return df_any  # type: ignore[no-any-return]
 
 
-def maybe_convert_dtypes(df: T, *args: bool, **kwargs: bool | str) -> T:
+def maybe_convert_dtypes(obj: T, *args: bool, **kwargs: bool | str) -> T:
     """
     Convert columns to the best possible dtypes using dtypes supporting ``pd.NA``, if df is pandas-like.
 
+    Arguments:
+        obj: DataFrame or Series.
+        *args: Additional arguments which gets passed through.
+        **kwargs: Additional arguments which gets passed through.
+
     Notes:
         For non-pandas-like inputs, this is a no-op.
         Also, `args` and `kwargs` just get passed down to the underlying library as-is.
@@ -298,16 +304,21 @@ def maybe_convert_dtypes(df: T, *args: bool, **kwargs: bool | str) -> T:
         b           boolean
         dtype: object
     """
-    from narwhals._pandas_like.dataframe import PandasLikeDataFrame
-
-    df_any = cast(Any, df)
-    if isinstance(getattr(df_any, "_compliant_frame", None), PandasLikeDataFrame):
-        return df_any._from_compliant_dataframe(  # type: ignore[no-any-return]
-            df_any._compliant_frame._from_native_frame(
-                df_any._compliant_frame._native_frame.convert_dtypes(*args, **kwargs)
+    obj_any = cast(Any, obj)
+    native_obj = to_native(obj_any)
+    if is_pandas_like_dataframe(native_obj):
+        return obj_any._from_compliant_dataframe(  # type: ignore[no-any-return]
+            obj_any._compliant_frame._from_native_frame(
+                native_obj.convert_dtypes(*args, **kwargs)
+            )
+        )
+    if is_pandas_like_series(native_obj):
+        return obj_any._from_compliant_series(  # type: ignore[no-any-return]
+            obj_any._compliant_series._from_native_series(
+                native_obj.convert_dtypes(*args, **kwargs)
             )
         )
-    return df
+    return obj_any  # type: ignore[no-any-return]
 
 
 def is_ordered_categorical(series: Series) -> bool:
diff --git a/noxfile.py b/noxfile.py
index 9e0508b7..06cdc028 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -23,17 +23,20 @@ def run_common(session: Session, coverage_threshold: float) -> None:
 
 @nox.session(python=PYTHON_VERSIONS)  # type: ignore[misc]
 def pytest_coverage(session: Session) -> None:
-    coverage_threshold = 90 if session.python == "3.8" else 100
-
-    session.install("modin[dask]")
+    if session.python == "3.8":
+        coverage_threshold = 85
+    else:
+        coverage_threshold = 100
+        session.install("modin[dask]")
 
     run_common(session, coverage_threshold)
 
 
 @nox.session(python=PYTHON_VERSIONS[0])  # type: ignore[misc]
-def minimum_versions(session: Session) -> None:
+@nox.parametrize("pandas_version", ["0.25.3", "1.1.5"])  # type: ignore[misc]
+def min_and_old_versions(session: Session, pandas_version: str) -> None:
     session.install(
-        "pandas==0.25.3",
+        f"pandas=={pandas_version}",
         "polars==0.20.3",
         "numpy==1.17.5",
         "pyarrow==11.0.0",
@@ -46,12 +49,29 @@ def minimum_versions(session: Session) -> None:
 
 @nox.session(python=PYTHON_VERSIONS[-1])  # type: ignore[misc]
 def nightly_versions(session: Session) -> None:
-    session.install("modin[dask]", "polars")
-    session.install(
+    session.install("polars")
+
+    session.install(  # pandas nightly
         "--pre",
         "--extra-index-url",
         "https://pypi.anaconda.org/scientific-python-nightly-wheels/simple",
         "pandas",
     )
 
+    session.install(  # numpy nightly
+        "--pre",
+        "--extra-index-url",
+        "https://pypi.anaconda.org/scientific-python-nightly-wheels/simple",
+        "numpy",
+    )
+
+    session.run("uv", "pip", "install", "pip")
+    session.run(  # dask nightly
+        "pip",
+        "install",
+        "git+https://github.com/dask/distributed",
+        "git+https://github.com/dask/dask",
+        "git+https://github.com/dask/dask-expr",
+    )
+
     run_common(session, coverage_threshold=50)
diff --git a/pyproject.toml b/pyproject.toml
index 83e3329a..a9a1c539 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "narwhals"
-version = "1.5.3"
+version = "1.5.4"
 authors = [
   { name="Marco Gorelli", email="33491632+MarcoGorelli@users.noreply.github.com" },
 ]
@@ -26,12 +26,12 @@ exclude = [
 ]
 
 [project.optional-dependencies]
-cudf = ["cudf>=23.08.00"]
+cudf = ["cudf>=23.08.00; python_version >= '3.9'"]
 modin = ["modin"]
 pandas = ["pandas>=0.25.3"]
 polars = ["polars>=0.20.3"]
-pyarrow = ['pyarrow>=11.0.0']
-dask = ['dask[dataframe]>=2024.7']
+pyarrow = ["pyarrow>=11.0.0"]
+dask = ["dask[dataframe]>=2024.7; python_version >= '3.9'"]
 
 [project.urls]
 "Homepage" = "https://github.com/narwhals-dev/narwhals"


Test patch:
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index 7f165742..0458a472 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -29,7 +29,7 @@ jobs:
       - name: show-deps
         run: uv pip freeze
       - name: Run pytest
-        run: pytest tests --cov=narwhals --cov=tests --cov-fail-under=90
+        run: pytest tests --cov=narwhals --cov=tests --cov-fail-under=85
       - name: Run doctests
         run: pytest narwhals --doctest-modules
 
diff --git a/tests/expr_and_series/filter_test.py b/tests/expr_and_series/filter_test.py
index ede0c49a..b55a0368 100644
--- a/tests/expr_and_series/filter_test.py
+++ b/tests/expr_and_series/filter_test.py
@@ -27,3 +27,6 @@ def test_filter_series(constructor_eager: Any) -> None:
     result = df.select(df["a"].filter((df["i"] < 2) & (df["c"] == 5)))
     expected = {"a": [0]}
     compare_dicts(result, expected)
+    result_s = df["a"].filter([True, False, False, False, False])
+    expected = {"a": [0]}
+    compare_dicts({"a": result_s}, expected)
diff --git a/tests/expr_and_series/sum_horizontal_test.py b/tests/expr_and_series/sum_horizontal_test.py
index 9411903c..4c4ab924 100644
--- a/tests/expr_and_series/sum_horizontal_test.py
+++ b/tests/expr_and_series/sum_horizontal_test.py
@@ -18,3 +18,12 @@ def test_sumh(constructor: Any, col_expr: Any) -> None:
         "horizontal_sum": [5, 7, 8],
     }
     compare_dicts(result, expected)
+
+
+def test_sumh_nullable(constructor: Any) -> None:
+    data = {"a": [1, 8, 3], "b": [4, 5, None]}
+    expected = {"hsum": [5, 13, 3]}
+
+    df = nw.from_native(constructor(data))
+    result = df.select(hsum=nw.sum_horizontal("a", "b"))
+    compare_dicts(result, expected)
diff --git a/tests/frame/filter_test.py b/tests/frame/filter_test.py
index 9b7ed45d..a8d3144a 100644
--- a/tests/frame/filter_test.py
+++ b/tests/frame/filter_test.py
@@ -12,11 +12,9 @@ def test_filter(constructor: Any) -> None:
     compare_dicts(result, expected)
 
 
-def test_filter_series(constructor_eager: Any) -> None:
+def test_filter_with_boolean_list(constructor: Any) -> None:
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
-    df = nw.from_native(constructor_eager(data), eager_only=True).with_columns(
-        mask=nw.col("a") > 1
-    )
-    result = df.filter(df["mask"]).drop("mask")
+    df = nw.from_native(constructor(data))
+    result = df.filter([False, True, True])
     expected = {"a": [3, 2], "b": [4, 6], "z": [8.0, 9.0]}
     compare_dicts(result, expected)
diff --git a/tests/frame/get_column_test.py b/tests/frame/get_column_test.py
index 70f65562..58766ac3 100644
--- a/tests/frame/get_column_test.py
+++ b/tests/frame/get_column_test.py
@@ -24,6 +24,9 @@ def test_non_string_name() -> None:
     result = nw.from_native(df, eager_only=True).get_column(0)  # type: ignore[arg-type]
     compare_dicts({"a": result}, {"a": [1, 2]})
     assert result.name == 0  # type: ignore[comparison-overlap]
-    with pytest.raises(TypeError, match="Expected str or slice"):
-        # Check that getitem would have raised
-        nw.from_native(df, eager_only=True)[0]  # type: ignore[call-overload]
+
+
+def test_get_single_row() -> None:
+    df = pd.DataFrame({"a": [1, 2], "b": [3, 4]})
+    result = nw.from_native(df, eager_only=True)[0]  # type: ignore[call-overload]
+    compare_dicts(result, {"a": [1], "b": [3]})
diff --git a/tests/frame/row_test.py b/tests/frame/row_test.py
new file mode 100644
index 00000000..602c50f5
--- /dev/null
+++ b/tests/frame/row_test.py
@@ -0,0 +1,14 @@
+from typing import Any
+
+import narwhals.stable.v1 as nw
+
+
+def test_row_column(constructor_eager: Any) -> None:
+    data = {
+        "a": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
+        "b": [11, 12, 13, 14, 15, 16],
+    }
+    result = nw.from_native(constructor_eager(data), eager_only=True).row(2)
+    if "pyarrow_table" in str(constructor_eager):
+        result = tuple(x.as_py() for x in result)
+    assert result == (3.0, 13)
diff --git a/tests/frame/select_test.py b/tests/frame/select_test.py
index 27a45ff0..450e9106 100644
--- a/tests/frame/select_test.py
+++ b/tests/frame/select_test.py
@@ -1,6 +1,7 @@
 from typing import Any
 
 import pandas as pd
+import pytest
 
 import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
@@ -21,5 +22,12 @@ def test_empty_select(constructor: Any) -> None:
 
 def test_non_string_select() -> None:
     df = nw.from_native(pd.DataFrame({0: [1, 2], "b": [3, 4]}))
-    result = nw.to_native(df.select(0))  # type: ignore[arg-type]
-    pd.testing.assert_frame_equal(result, pd.Series([1, 2], name=0).to_frame())
+    result = nw.to_native(df.select(nw.col(0)))  # type: ignore[arg-type]
+    expected = pd.Series([1, 2], name=0).to_frame()
+    pd.testing.assert_frame_equal(result, expected)
+
+
+def test_non_string_select_invalid() -> None:
+    df = nw.from_native(pd.DataFrame({0: [1, 2], "b": [3, 4]}))
+    with pytest.raises(TypeError, match="\n\nHint: if you were trying to select"):
+        nw.to_native(df.select(0))  # type: ignore[arg-type]
diff --git a/tests/frame/slice_test.py b/tests/frame/slice_test.py
index 222717d1..eea94d44 100644
--- a/tests/frame/slice_test.py
+++ b/tests/frame/slice_test.py
@@ -49,9 +49,16 @@ def test_slice_lazy_fails() -> None:
         _ = nw.from_native(pl.LazyFrame(data))[1:]
 
 
-def test_slice_int_fails(constructor_eager: Any) -> None:
-    with pytest.raises(TypeError, match="Expected str or slice, got: <class 'int'>"):
-        _ = nw.from_native(constructor_eager(data))[1]  # type: ignore[call-overload,index]
+def test_slice_int(constructor_eager: Any) -> None:
+    result = nw.from_native(constructor_eager(data), eager_only=True)[1]  # type: ignore[call-overload]
+    compare_dicts(result, {"a": [2], "b": [12]})
+
+
+def test_slice_fails(constructor_eager: Any) -> None:
+    class Foo: ...
+
+    with pytest.raises(TypeError, match="Expected str or slice, got:"):
+        nw.from_native(constructor_eager(data), eager_only=True)[Foo()]  # type: ignore[call-overload]
 
 
 def test_gather(constructor_eager: Any) -> None:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index f8b9b98e..b94c8371 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -72,10 +72,15 @@ def test_maybe_set_index_polars() -> None:
 def test_maybe_convert_dtypes_pandas() -> None:
     import numpy as np
 
-    df = nw.from_native(pd.DataFrame({"a": [1, np.nan]}, dtype=np.dtype("float64")))
+    df = nw.from_native(
+        pd.DataFrame({"a": [1, np.nan]}, dtype=np.dtype("float64")), eager_only=True
+    )
     result = nw.to_native(nw.maybe_convert_dtypes(df))
     expected = pd.DataFrame({"a": [1, pd.NA]}, dtype="Int64")
     pd.testing.assert_frame_equal(result, expected)
+    result_s = nw.to_native(nw.maybe_convert_dtypes(df["a"]))
+    expected_s = pd.Series([1, pd.NA], name="a", dtype="Int64")
+    pd.testing.assert_series_equal(result_s, expected_s)
 
 
 def test_maybe_convert_dtypes_polars() -> None: