Repository: narwhals-dev/narwhals

Issue:
Allow slicing eager dataframes
i.e.
```
df = nw.from_native(df_any)
df[1:]
```
should select all rows except for the first one

Discussion:

Patch:
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index 683d120b..687b1134 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING
 from typing import Any
 from typing import Iterable
 from typing import Literal
+from typing import overload
 
 from narwhals._pandas_like.utils import create_native_series
 from narwhals._pandas_like.utils import evaluate_into_exprs
@@ -82,13 +83,31 @@ class PandasDataFrame:
             implementation=self._implementation,
         )
 
-    def __getitem__(self, column_name: str) -> PandasSeries:
-        from narwhals._pandas_like.series import PandasSeries
+    @overload
+    def __getitem__(self, item: str) -> PandasSeries: ...
 
-        return PandasSeries(
-            self._dataframe.loc[:, column_name],
-            implementation=self._implementation,
-        )
+    @overload
+    def __getitem__(self, item: range | slice) -> PandasDataFrame: ...
+
+    def __getitem__(self, item: str | range | slice) -> PandasSeries | PandasDataFrame:
+        if isinstance(item, str):
+            from narwhals._pandas_like.series import PandasSeries
+
+            return PandasSeries(
+                self._dataframe.loc[:, item],
+                implementation=self._implementation,
+            )
+
+        elif isinstance(item, (range, slice)):
+            from narwhals._pandas_like.dataframe import PandasDataFrame
+
+            return PandasDataFrame(
+                self._dataframe.iloc[item], implementation=self._implementation
+            )
+
+        else:  # pragma: no cover
+            msg = f"Expected str, range or slice, got: {type(item)}"
+            raise TypeError(msg)
 
     # --- properties ---
     @property
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index b4867ae3..c5f91164 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -6,6 +6,7 @@ from typing import Callable
 from typing import Iterable
 from typing import Literal
 from typing import Sequence
+from typing import overload
 
 from narwhals._pandas_like.dataframe import PandasDataFrame
 from narwhals.dependencies import get_polars
@@ -368,10 +369,24 @@ class DataFrame(BaseFrame):
         """
         return self._dataframe.shape  # type: ignore[no-any-return]
 
-    def __getitem__(self, col_name: str) -> Series:
-        from narwhals.series import Series
+    @overload
+    def __getitem__(self, item: str) -> Series: ...
+
+    @overload
+    def __getitem__(self, item: range | slice) -> DataFrame: ...
+
+    def __getitem__(self, item: str | range | slice) -> Series | DataFrame:
+        if isinstance(item, str):
+            from narwhals.series import Series
+
+            return Series(self._dataframe[item])
 
-        return Series(self._dataframe[col_name])
+        elif isinstance(item, (range, slice)):
+            return DataFrame(self._dataframe[item])
+
+        else:
+            msg = f"Expected str, range or slice, got: {type(item)}"
+            raise TypeError(msg)
 
     def to_dict(self, *, as_series: bool = True) -> dict[str, Any]:
         r"""
@@ -1631,6 +1646,9 @@ class LazyFrame(BaseFrame):
             + "┘"
         )
 
+    def __getitem__(self, item: str | range | slice) -> Series | DataFrame:
+        raise TypeError("Slicing is not supported on LazyFrame")
+
     def collect(self) -> DataFrame:
         r"""
         Materialize this LazyFrame into a DataFrame.
diff --git a/narwhals/series.py b/narwhals/series.py
index f6138aa4..e98d41a0 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -767,37 +767,6 @@ class Series:
         return self._series.to_numpy()
 
     def to_pandas(self) -> Any:
-        """
-        Convert to pandas.
-
-        Examples:
-            >>> import pandas as pd
-            >>> import polars as pl
-            >>> import narwhals as nw
-            >>> s = [1, 2, 3]
-            >>> s_pd = pd.Series(s, name='a')
-            >>> s_pl = pl.Series('a', s)
-
-            We define a library agnostic function:
-
-            >>> def func(s_any):
-            ...     s = nw.from_native(s_any, series_only=True)
-            ...     df = s.to_pandas()
-            ...     return df
-
-            We can then pass either pandas or Polars to `func`:
-
-            >>> func(s_pd)
-            0    1
-            1    2
-            2    3
-            Name: a, dtype: int64
-            >>> func(s_pl)
-            0    1
-            1    2
-            2    3
-            Name: a, dtype: int64
-        """
         return self._series.to_pandas()
 
     def __eq__(self, other: object) -> Series:  # type: ignore[override]


Test patch:
diff --git a/tests/frame/slice_test.py b/tests/frame/slice_test.py
new file mode 100644
index 00000000..634653b0
--- /dev/null
+++ b/tests/frame/slice_test.py
@@ -0,0 +1,49 @@
+from typing import Any
+
+import pandas as pd
+import polars as pl
+import pytest
+
+import narwhals as nw
+from tests.utils import compare_dicts
+
+data = {
+    "a": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
+    "b": [11, 12, 13, 14, 15, 16],
+}
+
+
+@pytest.mark.parametrize("constructor", [pd.DataFrame, pl.DataFrame])
+def test_slice_column(constructor: Any) -> None:
+    result = nw.from_native(constructor(data))["a"]
+    assert isinstance(result, nw.Series)
+    assert result.to_numpy().tolist() == [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
+
+
+@pytest.mark.parametrize("constructor", [pd.DataFrame, pl.DataFrame])
+def test_slice_rows(constructor: Any) -> None:
+    result = nw.from_native(constructor(data))[1:]
+    compare_dicts(result, {"a": [2.0, 3.0, 4.0, 5.0, 6.0], "b": [12, 13, 14, 15, 16]})
+
+    result = nw.from_native(constructor(data))[2:4]
+    compare_dicts(result, {"a": [3.0, 4.0], "b": [13, 14]})
+
+
+@pytest.mark.parametrize("constructor", [pd.DataFrame, pl.DataFrame])
+def test_slice_rows_with_step(constructor: Any) -> None:
+    result = nw.from_native(constructor(data))[1::2]
+    compare_dicts(result, {"a": [2.0, 4.0, 6.0], "b": [12, 14, 16]})
+
+
+@pytest.mark.parametrize("constructor", [pl.LazyFrame])
+def test_slice_lazy_fails(constructor: Any) -> None:
+    with pytest.raises(TypeError, match="Slicing is not supported on LazyFrame"):
+        _ = nw.from_native(constructor(data))[1:]
+
+
+@pytest.mark.parametrize("constructor", [pd.DataFrame, pl.DataFrame])
+def test_slice_int_fails(constructor: Any) -> None:
+    with pytest.raises(
+        TypeError, match="Expected str, range or slice, got: <class 'int'>"
+    ):
+        _ = nw.from_native(constructor(data))[1]  # type: ignore[call-overload,index]