Repository: narwhals-dev/narwhals

Issue:
[Bug]: Can not subset using `tuple()`
### Describe the bug

[Docs say](https://narwhals-dev.github.io/narwhals/api-reference/dataframe/#narwhals.dataframe.DataFrame.__getitem) you can subset using a `Sequence`. This should support `tuple()`, not just `list()`

### Steps or code to reproduce the bug

```python
import narwhals as nw
import pandas as pd
import polars as pl

DATA={ "x": [1,2,3] }
pd_df = nw.from_native(pd.DataFrame(DATA), eager_only=True)
pl_df = nw.from_native(pl.DataFrame(DATA), eager_only=True)

print("nw tuple subset")

print("list, list ; √")
print("tuple, list ; X")
print("list, tuple ; X")
print("tuple, tuple ; X")
print("")

tuple_row = (0,2)
list_row = list(tuple_row)
tuple_col = (0,)
list_col = list(tuple_col)

print("pd_df[list_row, list_col].shape:", pd_df[list_row, list_col].shape)
try:
    pd_df[tuple_row, list_col].shape
except pd.errors.IndexingError as e:
    print("pd_df[tuple_row, list_col].shape Error:", str(e))
try:
    pd_df[list_row, tuple_col].shape
except pd.errors.IndexingError as e:
    print("pd_df[list_row, tuple_col].shape Error:", str(e))
try:
    pd_df[tuple_row, tuple_col].shape
except pd.errors.IndexingError as e:
    print("pd_df[tuple_row, tuple_col].shape Error:", str(e))
```

### Expected results

All four examples should not throw an error and instead subset just like the list indexers

### Actual results

```
nw tuple subset
list, list ; √
tuple, list ; X
list, tuple ; X
tuple, tuple ; X

pd_df[list_row, list_col].shape: (2, 1)
pd_df[tuple_row, list_col].shape Error: Too many indexers
pd_df[list_row, tuple_col].shape Error: Too many indexers
pd_df[tuple_row, tuple_col].shape Error: Too many indexers
```

### Please run narwhals.show_version() and enter the output below.

```shell
System:
    python: 3.12.4 (main, Jun  6 2024, 18:26:44) [Clang 15.0.0 (clang-1500.3.9.4)]
executable: /path/to/python
   machine: macOS-14.6.1-arm64-arm-64bit

Python dependencies:
     narwhals: 1.8.1
       pandas: 2.2.2
       polars: 1.5.0
         cudf: 
        modin: 
      pyarrow: 
        numpy: 2.1.0
```
```


### Relevant log output

_No response_

Discussion:
windiana42: This PR is ready for review.
mikeweltevrede: @MarcoGorelli @Riik @windiana42 PS: Here is the video that showed me that `typing` is really just aliasing `collections` for a lot of type hints: https://www.youtube.com/watch?v=cv1F_c66utw

Patch:
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index 5b74ddb7..89919c76 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -136,6 +136,9 @@ class ArrowDataFrame:
         | tuple[slice, str | int]
         | tuple[slice, slice],
     ) -> ArrowSeries | ArrowDataFrame:
+        if isinstance(item, tuple):
+            item = tuple(list(i) if is_sequence_but_not_str(i) else i for i in item)
+
         if isinstance(item, str):
             from narwhals._arrow.series import ArrowSeries
 
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index 53481c2f..446c68a1 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -124,6 +124,14 @@ class PandasLikeDataFrame:
     @overload
     def __getitem__(self, item: tuple[slice, slice]) -> Self: ...
 
+    @overload
+    def __getitem__(
+        self, item: tuple[Sequence[int], Sequence[int] | slice]
+    ) -> PandasLikeDataFrame: ...
+
+    @overload
+    def __getitem__(self, item: tuple[slice, Sequence[int]]) -> PandasLikeDataFrame: ...
+
     def __getitem__(
         self,
         item: str
@@ -132,8 +140,12 @@ class PandasLikeDataFrame:
         | Sequence[int]
         | Sequence[str]
         | tuple[Sequence[int], str | int]
+        | tuple[slice | Sequence[int], Sequence[int] | slice]
         | tuple[slice, slice],
     ) -> PandasLikeSeries | PandasLikeDataFrame:
+        if isinstance(item, tuple):
+            item = tuple(list(i) if is_sequence_but_not_str(i) else i for i in item)
+
         if isinstance(item, str):
             from narwhals._pandas_like.series import PandasLikeSeries
 
diff --git a/narwhals/_polars/dataframe.py b/narwhals/_polars/dataframe.py
index 6f61853f..77d8a016 100644
--- a/narwhals/_polars/dataframe.py
+++ b/narwhals/_polars/dataframe.py
@@ -91,6 +91,9 @@ class PolarsDataFrame:
         else:  # pragma: no cover
             # TODO(marco): we can delete this branch after Polars==0.20.30 becomes the minimum
             # Polars version we support
+            if isinstance(item, tuple):
+                item = tuple(list(i) if is_sequence_but_not_str(i) else i for i in item)
+
             columns = self.columns
             if isinstance(item, tuple) and len(item) == 2 and isinstance(item[1], slice):
                 if isinstance(item[1].start, str) or isinstance(item[1].stop, str):


Test patch:
diff --git a/tests/frame/slice_test.py b/tests/frame/getitem_test.py
similarity index 82%
rename from tests/frame/slice_test.py
rename to tests/frame/getitem_test.py
index a832daa6..894555b3 100644
--- a/tests/frame/slice_test.py
+++ b/tests/frame/getitem_test.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 from typing import Any
 
 import numpy as np
@@ -193,3 +195,58 @@ def test_slice_edge_cases(constructor_eager: Any) -> None:
     assert df[[], ["a"]].shape == (0, 1)
     assert df[:, :].shape == (3, 4)
     assert df[[], []].shape == (0, 0)
+
+
+@pytest.mark.parametrize(
+    ("row_idx", "col_idx"),
+    [
+        ([0, 2], [0]),
+        ((0, 2), [0]),
+        ([0, 2], (0,)),
+        ((0, 2), (0,)),
+        ([0, 2], range(1)),
+        (range(2), [0]),
+        (range(2), range(1)),
+    ],
+)
+def test_get_item_works_with_tuple_and_list_and_range_row_and_col_indexing(
+    constructor_eager: Any,
+    row_idx: list[int] | tuple[int] | range,
+    col_idx: list[int] | tuple[int] | range,
+) -> None:
+    nw_df = nw.from_native(constructor_eager(data), eager_only=True)
+    nw_df[row_idx, col_idx]
+
+
+@pytest.mark.parametrize(
+    ("row_idx", "col"),
+    [
+        ([0, 2], slice(1)),
+        ((0, 2), slice(1)),
+        (range(2), slice(1)),
+    ],
+)
+def test_get_item_works_with_tuple_and_list_and_range_row_indexing_and_slice_col_indexing(
+    constructor_eager: Any,
+    row_idx: list[int] | tuple[int] | range,
+    col: slice,
+) -> None:
+    nw_df = nw.from_native(constructor_eager(data), eager_only=True)
+    nw_df[row_idx, col]
+
+
+@pytest.mark.parametrize(
+    ("row_idx", "col"),
+    [
+        ([0, 2], "a"),
+        ((0, 2), "a"),
+        (range(2), "a"),
+    ],
+)
+def test_get_item_works_with_tuple_and_list_indexing_and_str(
+    constructor_eager: Any,
+    row_idx: list[int] | tuple[int] | range,
+    col: str,
+) -> None:
+    nw_df = nw.from_native(constructor_eager(data), eager_only=True)
+    nw_df[row_idx, col]