Repository: narwhals-dev/narwhals

Issue:
[Enh]: support `__getitem__` with single tuple of column names
### We would like to learn about your use case. For example, if this feature is needed  to adopt Narwhals in an open source project, could you please enter the link to it below?


_No response_

### Please describe the purpose of the new feature or describe the problem to solve.

`df[:, column_names]` is supported, I think `df[column_names]` should be too

### Suggest a solution if possible.

_No response_

### If you have tried alternatives, please describe them below.

_No response_

### Additional information that may help us understand your needs.

_No response_

Discussion:

Patch:
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index fa5a6995..f409ef73 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -121,7 +121,12 @@ class ArrowDataFrame:
     def __getitem__(self, item: slice) -> ArrowDataFrame: ...
 
     def __getitem__(
-        self, item: str | slice | Sequence[int] | tuple[Sequence[int], str | int]
+        self,
+        item: str
+        | slice
+        | Sequence[int]
+        | Sequence[str]
+        | tuple[Sequence[int], str | int],
     ) -> ArrowSeries | ArrowDataFrame:
         if isinstance(item, str):
             from narwhals._arrow.series import ArrowSeries
@@ -191,6 +196,8 @@ class ArrowDataFrame:
             )
 
         elif isinstance(item, Sequence) or (is_numpy_array(item) and item.ndim == 1):
+            if isinstance(item, Sequence) and all(isinstance(x, str) for x in item):
+                return self._from_native_frame(self._native_frame.select(item))
             return self._from_native_frame(self._native_frame.take(item))
 
         else:  # pragma: no cover
diff --git a/narwhals/_dask/dataframe.py b/narwhals/_dask/dataframe.py
index e2a034ae..d4433fb3 100644
--- a/narwhals/_dask/dataframe.py
+++ b/narwhals/_dask/dataframe.py
@@ -79,14 +79,15 @@ class DaskLazyFrame:
             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
+            msg = "Filtering with boolean mask is not supported for `DaskLazyFrame`"
+            raise NotImplementedError(msg)
+
+        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/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index 59cff49f..71a65999 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -111,13 +111,22 @@ class PandasLikeDataFrame:
     def __getitem__(self, item: Sequence[int]) -> PandasLikeDataFrame: ...
 
     @overload
-    def __getitem__(self, item: str) -> PandasLikeSeries: ...
+    def __getitem__(self, item: str) -> PandasLikeSeries: ...  # type: ignore[overload-overlap]
+
+    @overload
+    def __getitem__(self, item: Sequence[str]) -> PandasLikeDataFrame: ...
 
     @overload
     def __getitem__(self, item: slice) -> PandasLikeDataFrame: ...
 
     def __getitem__(
-        self, item: str | slice | Sequence[int] | tuple[Sequence[int], str | int]
+        self,
+        item: str
+        | int
+        | slice
+        | Sequence[int]
+        | Sequence[str]
+        | tuple[Sequence[int], str | int],
     ) -> PandasLikeSeries | PandasLikeDataFrame:
         if isinstance(item, str):
             from narwhals._pandas_like.series import PandasLikeSeries
@@ -174,7 +183,7 @@ class PandasLikeDataFrame:
             from narwhals._pandas_like.series import PandasLikeSeries
 
             if isinstance(item[1], str):
-                item = (item[0], self._native_frame.columns.get_loc(item[1]))
+                item = (item[0], self._native_frame.columns.get_loc(item[1]))  # type: ignore[assignment]
                 native_series = self._native_frame.iloc[item]
             elif isinstance(item[1], int):
                 native_series = self._native_frame.iloc[item]
@@ -191,6 +200,8 @@ class PandasLikeDataFrame:
         elif isinstance(item, (slice, Sequence)) or (
             is_numpy_array(item) and item.ndim == 1
         ):
+            if isinstance(item, Sequence) and all(isinstance(x, str) for x in item):
+                return self._from_native_frame(self._native_frame.loc[:, item])
             return self._from_native_frame(self._native_frame.iloc[item])
 
         else:  # pragma: no cover
@@ -590,8 +601,8 @@ class PandasLikeDataFrame:
         from narwhals._pandas_like.series import PANDAS_TO_NUMPY_DTYPE_MISSING
 
         if copy is None:
-            # pandas default differs from Polars
-            copy = False
+            # pandas default differs from Polars, but cuDF default is True
+            copy = self._implementation is Implementation.CUDF
 
         if dtype is not None:
             return self._native_frame.to_numpy(dtype=dtype, copy=copy)
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index b28a0408..a0830784 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -473,7 +473,7 @@ class PandasLikeSeries:
     def to_numpy(self, dtype: Any = None, copy: bool | None = None) -> Any:
         # the default is meant to be None, but pandas doesn't allow it?
         # https://numpy.org/doc/stable/reference/generated/numpy.ndarray.__array__.html
-        copy = copy or False
+        copy = copy or self._implementation is Implementation.CUDF
 
         has_missing = self._native_series.isna().any()
         if (
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index a266b73c..1b91f091 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -612,7 +612,10 @@ class DataFrame(BaseFrame[FrameT]):
     def __getitem__(self, item: Sequence[int]) -> Self: ...
 
     @overload
-    def __getitem__(self, item: str) -> Series: ...
+    def __getitem__(self, item: str) -> Series: ...  # type: ignore[overload-overlap]
+
+    @overload
+    def __getitem__(self, item: Sequence[str]) -> Self: ...
 
     @overload
     def __getitem__(self, item: slice) -> Self: ...
@@ -622,6 +625,7 @@ class DataFrame(BaseFrame[FrameT]):
         item: str
         | slice
         | Sequence[int]
+        | Sequence[str]
         | tuple[Sequence[int], str | int]
         | tuple[slice | Sequence[int], Sequence[int] | Sequence[str] | slice],
     ) -> Series | Self:
@@ -644,6 +648,8 @@ class DataFrame(BaseFrame[FrameT]):
                   `DataFrame`.
                 - `df[:, ['a', 'c']]` extracts all rows and columns `'a'` and `'c'` and returns a
                   `DataFrame`.
+                - `df[['a', 'c']]` extracts all rows and columns `'a'` and `'c'` and returns a
+                  `DataFrame`.
                 - `df[0: 2, ['a', 'c']]` extracts the first two rows and columns `'a'` and `'c'` and
                     returns a `DataFrame`
                 - `df[:, 0: 2]` extracts all rows from the first two columns and returns a `DataFrame`
diff --git a/narwhals/stable/v1.py b/narwhals/stable/v1.py
index 1af7a26f..862ba5d1 100644
--- a/narwhals/stable/v1.py
+++ b/narwhals/stable/v1.py
@@ -91,9 +91,10 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
 
     @overload
     def __getitem__(self, item: Sequence[int]) -> Self: ...
-
     @overload
-    def __getitem__(self, item: str) -> Series: ...
+    def __getitem__(self, item: str) -> Series: ...  # type: ignore[overload-overlap]
+    @overload
+    def __getitem__(self, item: Sequence[str]) -> Self: ...
 
     @overload
     def __getitem__(self, item: slice) -> Self: ...
@@ -1541,7 +1542,7 @@ def is_ordered_categorical(series: Series) -> bool:
 
 def maybe_align_index(lhs: T, rhs: Series | DataFrame[Any] | LazyFrame[Any]) -> T:
     """
-    Align `lhs` to the Index of `rhs, if they're both pandas-like.
+    Align `lhs` to the Index of `rhs`, if they're both pandas-like.
 
     Notes:
         This is only really intended for backwards-compatibility purposes,
diff --git a/narwhals/utils.py b/narwhals/utils.py
index 6c1b5c1b..ec3c722d 100644
--- a/narwhals/utils.py
+++ b/narwhals/utils.py
@@ -153,7 +153,7 @@ def validate_laziness(items: Iterable[Any]) -> None:
 
 def maybe_align_index(lhs: T, rhs: Series | BaseFrame[Any]) -> T:
     """
-    Align `lhs` to the Index of `rhs, if they're both pandas-like.
+    Align `lhs` to the Index of `rhs`, if they're both pandas-like.
 
     Notes:
         This is only really intended for backwards-compatibility purposes,


Test patch:
diff --git a/tests/frame/filter_test.py b/tests/frame/filter_test.py
index a8d3144a..609f8ef9 100644
--- a/tests/frame/filter_test.py
+++ b/tests/frame/filter_test.py
@@ -1,5 +1,8 @@
+from contextlib import nullcontext as does_not_raise
 from typing import Any
 
+import pytest
+
 import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
@@ -15,6 +18,16 @@ def test_filter(constructor: 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(data))
-    result = df.filter([False, True, True])
-    expected = {"a": [3, 2], "b": [4, 6], "z": [8.0, 9.0]}
-    compare_dicts(result, expected)
+
+    context = (
+        pytest.raises(
+            NotImplementedError, match="Filtering with boolean mask is not supported"
+        )
+        if "dask" in str(constructor)
+        else does_not_raise()
+    )
+
+    with context:
+        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/slice_test.py b/tests/frame/slice_test.py
index 18b05bf3..834e88bf 100644
--- a/tests/frame/slice_test.py
+++ b/tests/frame/slice_test.py
@@ -147,6 +147,9 @@ def test_slice_slice_columns(constructor_eager: Any) -> None:
     result = df[:, [0, 2]]
     expected = {"a": [1, 2, 3], "c": [7, 8, 9]}
     compare_dicts(result, expected)
+    result = df[["b", "c"]]
+    expected = {"b": [4, 5, 6], "c": [7, 8, 9]}
+    compare_dicts(result, expected)
 
 
 def test_slice_invalid(constructor_eager: Any) -> None: