Repository: narwhals-dev/narwhals
Pull request #1007 (2024-09-20 08:53:47)

docs: Restructure docs a bit
Factoring some parts out of https://github.com/narwhals-dev/narwhals/pull/988

<!--
# 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/docs/api-reference/index.md b/docs/api-reference/index.md
index 0c2c81fa..b4cbc78f 100644
--- a/docs/api-reference/index.md
+++ b/docs/api-reference/index.md
@@ -1,30 +1,20 @@
 # API Reference
 
-Anything documented in the API reference is intended to work consistently among
-supported backends.
-
-For example:
-```python
-import narwhals as nw
-
-df.with_columns(
-    a_mean=nw.col("a").mean(),
-    a_std=nw.col("a").std(),
-)
-```
-is supported, as `DataFrame.with_columns`, `narwhals.col`, `Expr.mean`, and `Expr.std` are
-all documented in the API reference.
-
-However,
-```python
-import narwhals as nw
-
-df.with_columns(
-    a_ewm_mean=nw.col("a").ewm_mean(alpha=0.7),
-)
-```
-is not - `Expr.ewm_mean` only appears in the Polars API reference, but not in the Narwhals
-one.
-
-In general, you should expect any fundamental dataframe operation to be supported - if
-one that you need is not, please do open a feature request!
+- [Top-level functions](narwhals.md)
+- [narwhals.DataFrame](dataframe.md)
+- [narwhals.Expr](expr.md)
+- [narwhals.Expr.cat](expr_cat.md)
+- [narwhals.Expr.dt](expr_dt.md)
+- [narwhals.Expr.name](expr_name.md)
+- [narwhals.Expr.str](expr_str.md)
+- [narwhals.GroupBy](group_by.md)
+- [narwhals.LazyFrame](lazyframe.md)
+- [narwhals.Schema](schema.md)
+- [narwhals.Series](series.md)
+- [narwhals.Series.cat](series_cat.md)
+- [narwhals.Series.dt](series_dt.md)
+- [narwhals.Series.str](series_str.md)
+- [narwhals.dependencies](dependencies.md)
+- [narwhals.dtypes](dtypes.md)
+- [narwhals.selectors](selectors.md)
+- [narwhals.typing](typing.md)
diff --git a/docs/extending.md b/docs/extending.md
index 1a750431..13b93387 100644
--- a/docs/extending.md
+++ b/docs/extending.md
@@ -1,4 +1,6 @@
-# List of supported libraries (and how to add yours!)
+# Extending Narwhals, levels of support
+
+## List of supported libraries (and how to add yours!)
 
 Currently, Narwhals supports the following libraries as inputs:
 
@@ -44,3 +46,47 @@ Make sure that, in addition to the public Narwhals API, you also define:
 
 Note that the "extension" mechanism is still experimental. If anything is not clear, or
 doesn't work, please do raise an issue or contact us on Discord (see the link on the README).
+
+## Levels
+
+Narwhals comes with two levels of support: "full" and "interchange".
+
+Libraries for which we have full support can benefit from the whole
+[Narwhals API](https://narwhals-dev.github.io/narwhals/api-reference/).
+
+For example:
+
+```python exec="1" source="above"
+import narwhals as nw
+from narwhals.typing import FrameT
+
+
+@nw.narwhalify
+def func(df: FrameT) -> FrameT:
+    return df.group_by("a").agg(
+        b_mean=nw.col("b").mean(),
+        b_std=nw.col("b").std(),
+    )
+```
+will work for any of pandas, Polars, cuDF, Modin, and PyArrow.
+
+However, sometimes you don't need to do complex operations on dataframes - all you need
+is to inspect the schema a bit before making other decisions, such as which columns to
+select or whether to convert to another library. For that purpose, we also provide "interchange"
+level of support. If a library implements the
+[Dataframe Interchange Protocol](https://data-apis.org/dataframe-protocol/latest/), then
+a call such as
+
+```python exec="1" source="above"
+from typing import Any
+
+import narwhals as nw
+from narwhals.schema import Schema
+
+
+def func(df: Any) -> Schema:
+    df = nw.from_native(df, eager_or_interchange_only=True)
+    return df.schema
+```
+is also supported, meaning that, in addition to the libraries mentioned above, you can
+also pass Ibis, Vaex, PyArrow, and any other library which implements the protocol.
diff --git a/docs/installation.md b/docs/installation.md
index 61760681..6dc2bfa9 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -1,4 +1,6 @@
-# Installation
+# Installation and quick start
+
+## Installation
 
 First, make sure you have [created and activated](https://docs.python.org/3/library/venv.html) a Python3.8+ virtual environment.
 
@@ -14,3 +16,49 @@ Then, if you start the Python REPL and see the following:
 '1.8.1'
 ```
 then installation worked correctly!
+
+## Quick start
+
+### Prerequisites
+
+Please start by following the [installation instructions](installation.md).
+
+To follow along with the examples which follow, please install the following (though note that
+they are not required dependencies - Narwhals only ever uses what the user passes in):
+
+- [pandas](https://pandas.pydata.org/docs/getting_started/install.html)
+- [Polars](https://pola-rs.github.io/polars/user-guide/installation/)
+
+### Simple example
+
+Create a Python file `t.py` with the following content:
+
+```python exec="1" source="above" session="quickstart" result="python"
+from __future__ import annotations
+
+import pandas as pd
+import polars as pl
+import narwhals as nw
+from narwhals.typing import IntoFrame
+
+
+def my_function(df_native: IntoFrame) -> list[str]:
+    df = nw.from_native(df_native)
+    column_names = df.columns
+    return column_names
+
+
+df_pandas = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
+df_polars = pl.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
+
+print("pandas output")
+print(my_function(df_pandas))
+print("Polars output")
+print(my_function(df_polars))
+```
+
+If you run `python t.py` then your output should look like the above. This is the simplest possible example of a dataframe-agnostic
+function - as we'll soon see, we can do much more advanced things.
+Let's learn about what you just did, and what Narwhals can do for you!
+
+Note: these examples are only using pandas and Polars. Please see the following to find the [supported libriaries](extending.md).
diff --git a/docs/levels.md b/docs/levels.md
deleted file mode 100644
index 74333466..00000000
--- a/docs/levels.md
+++ /dev/null
@@ -1,43 +0,0 @@
-# Levels
-
-Narwhals comes with two levels of support: "full" and "interchange".
-
-Libraries for which we have full support can benefit from the whole
-[Narwhals API](https://narwhals-dev.github.io/narwhals/api-reference/).
-
-For example:
-
-```python exec="1" source="above"
-import narwhals as nw
-from narwhals.typing import FrameT
-
-
-@nw.narwhalify
-def func(df: FrameT) -> FrameT:
-    return df.group_by("a").agg(
-        b_mean=nw.col("b").mean(),
-        b_std=nw.col("b").std(),
-    )
-```
-will work for any of pandas, Polars, cuDF, Modin, and PyArrow.
-
-However, sometimes you don't need to do complex operations on dataframes - all you need
-is to inspect the schema a bit before making other decisions, such as which columns to
-select or whether to convert to another library. For that purpose, we also provide "interchange"
-level of support. If a library implements the
-[Dataframe Interchange Protocol](https://data-apis.org/dataframe-protocol/latest/), then
-a call such as
-
-```python exec="1" source="above"
-from typing import Any
-
-import narwhals as nw
-from narwhals.schema import Schema
-
-
-def func(df: Any) -> Schema:
-    df = nw.from_native(df, eager_or_interchange_only=True)
-    return df.schema
-```
-is also supported, meaning that, in addition to the libraries mentioned above, you can
-also pass Ibis, Vaex, PyArrow, and any other library which implements the protocol.
diff --git a/docs/quick_start.md b/docs/quick_start.md
deleted file mode 100644
index f3ff8c05..00000000
--- a/docs/quick_start.md
+++ /dev/null
@@ -1,45 +0,0 @@
-# Quick start
-
-## Prerequisites
-
-Please start by following the [installation instructions](installation.md).
-
-To follow along with the examples which follow, please install the following (though note that
-they are not required dependencies - Narwhals only ever uses what the user passes in):
-
-- [pandas](https://pandas.pydata.org/docs/getting_started/install.html)
-- [Polars](https://pola-rs.github.io/polars/user-guide/installation/)
-
-## Simple example
-
-Create a Python file `t.py` with the following content:
-
-```python exec="1" source="above" session="quickstart" result="python"
-from __future__ import annotations
-
-import pandas as pd
-import polars as pl
-import narwhals as nw
-from narwhals.typing import IntoFrame
-
-
-def my_function(df_native: IntoFrame) -> list[str]:
-    df = nw.from_native(df_native)
-    column_names = df.columns
-    return column_names
-
-
-df_pandas = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
-df_polars = pl.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
-
-print("pandas output")
-print(my_function(df_pandas))
-print("Polars output")
-print(my_function(df_polars))
-```
-
-If you run `python t.py` then your output should look like the above. This is the simplest possible example of a dataframe-agnostic
-function - as we'll soon see, we can do much more advanced things.
-Let's learn about what you just did, and what Narwhals can do for you!
-
-Note: these examples are only using pandas and Polars. Please see the following to find the [supported libriaries](extending.md).
diff --git a/docs/related.md b/docs/related.md
deleted file mode 100644
index 38b0522d..00000000
--- a/docs/related.md
+++ /dev/null
@@ -1,13 +0,0 @@
-# Related projects
-
-## Dataframe Interchange Protocol
-
-Standardised way of interchanging data between libraries, see
-[here](https://data-apis.org/dataframe-protocol/latest/index.html).
-
-Narwhals builds upon it by providing one level of support to libraries which implement it -
-this includes Ibis and Vaex. See [levels](levels.md) for details.
-
-## Array API
-
-Array counterpart to the DataFrame API, see [here](https://data-apis.org/array-api/2022.12/index.html).
diff --git a/docs/roadmap.md b/docs/roadmap.md
deleted file mode 100644
index 87b224bf..00000000
--- a/docs/roadmap.md
+++ /dev/null
@@ -1,11 +0,0 @@
-# Roadmap
-
-Priorities, as of August 2024, are:
-
-- Works towards supporting projects which have shown interest in Narwhals.
-- Implement when/then/otherwise so that Narwhals is API-complete enough to complete all the TPC-H queries.
-- Make Dask support complete-enough, at least to the point that it can execute TPC-H queries.
-- Improve support for cuDF, which we can't currently test in CI (unless NVIDIA helps us out :wink:) but
-  which we can and do test manually in Kaggle notebooks.
-- Add extra docs and tutorials to make the project more accessible and easy to get started with.
-- Look into extra backends, such as DuckDB and Ibis.
diff --git a/docs/roadmap_and_related.md b/docs/roadmap_and_related.md
new file mode 100644
index 00000000..5e464683
--- /dev/null
+++ b/docs/roadmap_and_related.md
@@ -0,0 +1,25 @@
+# Roadmap and related projects
+
+## Roadmap
+
+Priorities, as of September 2024, are:
+
+- Works towards supporting projects which have shown interest in Narwhals.
+- Add extra docs and tutorials to make the project more accessible and easy to get started with.
+- Improve support for cuDF, which we can't currently test in CI (unless NVIDIA helps us out :wink:) but
+  which we can and do test manually in Kaggle notebooks.
+- Define a lazy-only layer of support which can include DuckDB, Ibis, and PySpark.
+
+## Related projects
+
+### Dataframe Interchange Protocol
+
+Standardised way of interchanging data between libraries, see
+[here](https://data-apis.org/dataframe-protocol/latest/index.html).
+
+Narwhals builds upon it by providing one level of support to libraries which implement it -
+this includes Ibis and Vaex. See [extending](extending.md) for details.
+
+### Array API
+
+Array counterpart to the DataFrame API, see [here](https://data-apis.org/array-api/2022.12/index.html).
diff --git a/mkdocs.yml b/mkdocs.yml
index 8b635f78..b0aaf106 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -5,8 +5,7 @@ watch:
 nav:
   - Home: index.md
   - Why: why.md
-  - Installation: installation.md
-  - Quick start: quick_start.md
+  - Installation and quick start: installation.md
   - Tutorial:
     - basics/dataframe.md
     - basics/column.md
@@ -15,18 +14,16 @@ nav:
     - other/pandas_index.md
     - other/user_warning.md
     - other/column_names.md
-  - levels.md
   - overhead.md
   - backcompat.md
   - extending.md
   - how_it_works.md
-  - Roadmap: roadmap.md
-  - Related projects: related.md
+  - Roadmap and related projects: roadmap_and_related.md
   - API Completeness:
     - api-completeness/index.md
-    - api-completeness/dataframe.md
-    - api-completeness/expr.md
-    - api-completeness/series.md
+    - Supported DataFrame methods: api-completeness/dataframe.md
+    - Supporteda Expr methods: api-completeness/expr.md
+    - Supported Series methods: api-completeness/series.md
   - API Reference:
     - api-reference/narwhals.md
     - api-reference/dataframe.md
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index 428e83e3..5b74ddb7 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -9,7 +9,8 @@ from typing import Sequence
 from typing import overload
 
 from narwhals._arrow.utils import broadcast_series
-from narwhals._arrow.utils import convert_slice_to_nparray
+from narwhals._arrow.utils import convert_str_slice_to_int_slice
+from narwhals._arrow.utils import select_rows
 from narwhals._arrow.utils import translate_dtype
 from narwhals._arrow.utils import validate_dataframe_comparand
 from narwhals._expression_parsing import evaluate_into_exprs
@@ -18,6 +19,7 @@ from narwhals.dependencies import is_numpy_array
 from narwhals.utils import Implementation
 from narwhals.utils import flatten
 from narwhals.utils import generate_unique_token
+from narwhals.utils import is_sequence_but_not_str
 from narwhals.utils import parse_columns_to_drop
 
 if TYPE_CHECKING:
@@ -121,6 +123,9 @@ class ArrowDataFrame:
     @overload
     def __getitem__(self, item: slice) -> ArrowDataFrame: ...
 
+    @overload
+    def __getitem__(self, item: tuple[slice, slice]) -> ArrowDataFrame: ...
+
     def __getitem__(
         self,
         item: str
@@ -128,7 +133,8 @@ class ArrowDataFrame:
         | Sequence[int]
         | Sequence[str]
         | tuple[Sequence[int], str | int]
-        | tuple[slice, str | int],
+        | tuple[slice, str | int]
+        | tuple[slice, slice],
     ) -> ArrowSeries | ArrowDataFrame:
         if isinstance(item, str):
             from narwhals._arrow.series import ArrowSeries
@@ -141,33 +147,19 @@ class ArrowDataFrame:
         elif (
             isinstance(item, tuple)
             and len(item) == 2
-            and isinstance(item[1], (list, tuple))
+            and is_sequence_but_not_str(item[1])
         ):
-            if item[0] == slice(None):
-                selected_rows = self._native_frame
-            else:
-                range_ = convert_slice_to_nparray(
-                    num_rows=len(self._native_frame), rows_slice=item[0]
-                )
-                selected_rows = self._native_frame.take(range_)
-
+            if len(item[1]) == 0:
+                # Return empty dataframe
+                return self._from_native_frame(self._native_frame.slice(0, 0).select([]))
+            selected_rows = select_rows(self._native_frame, item[0])
             return self._from_native_frame(selected_rows.select(item[1]))
 
         elif isinstance(item, tuple) and len(item) == 2:
             if isinstance(item[1], slice):
                 columns = self.columns
                 if isinstance(item[1].start, str) or isinstance(item[1].stop, str):
-                    start = (
-                        columns.index(item[1].start)
-                        if item[1].start is not None
-                        else None
-                    )
-                    stop = (
-                        columns.index(item[1].stop) + 1
-                        if item[1].stop is not None
-                        else None
-                    )
-                    step = item[1].step
+                    start, stop, step = convert_str_slice_to_int_slice(item[1], columns)
                     return self._from_native_frame(
                         self._native_frame.take(item[0]).select(columns[start:stop:step])
                     )
@@ -192,11 +184,9 @@ class ArrowDataFrame:
                     name=col_name,
                     backend_version=self._backend_version,
                 )
-            range_ = convert_slice_to_nparray(
-                num_rows=len(self._native_frame), rows_slice=item[0]
-            )
+            selected_rows = select_rows(self._native_frame, item[0])
             return ArrowSeries(
-                self._native_frame[col_name].take(range_),
+                selected_rows[col_name],
                 name=col_name,
                 backend_version=self._backend_version,
             )
@@ -205,15 +195,27 @@ class ArrowDataFrame:
             if item.step is not None and item.step != 1:
                 msg = "Slicing with step is not supported on PyArrow tables"
                 raise NotImplementedError(msg)
+            columns = self.columns
+            if isinstance(item.start, str) or isinstance(item.stop, str):
+                start, stop, step = convert_str_slice_to_int_slice(item, columns)
+                return self._from_native_frame(
+                    self._native_frame.select(columns[start:stop:step])
+                )
             start = item.start or 0
-            stop = item.stop or len(self._native_frame)
+            stop = item.stop if item.stop is not None else len(self._native_frame)
             return self._from_native_frame(
-                self._native_frame.slice(item.start, stop - start),
+                self._native_frame.slice(start, stop - start),
             )
 
         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):
+            if (
+                isinstance(item, Sequence)
+                and all(isinstance(x, str) for x in item)
+                and len(item) > 0
+            ):
                 return self._from_native_frame(self._native_frame.select(item))
+            if isinstance(item, Sequence) and len(item) == 0:
+                return self._from_native_frame(self._native_frame.slice(0, 0))
             return self._from_native_frame(self._native_frame.take(item))
 
         else:  # pragma: no cover
diff --git a/narwhals/_arrow/utils.py b/narwhals/_arrow/utils.py
index a2a45586..29bc3564 100644
--- a/narwhals/_arrow/utils.py
+++ b/narwhals/_arrow/utils.py
@@ -8,6 +8,8 @@ from narwhals import dtypes
 from narwhals.utils import isinstance_or_issubclass
 
 if TYPE_CHECKING:
+    import pyarrow as pa
+
     from narwhals._arrow.series import ArrowSeries
 
 
@@ -286,3 +288,23 @@ def convert_slice_to_nparray(
         return np.arange(num_rows)[rows_slice]
     else:
         return rows_slice
+
+
+def select_rows(table: pa.Table, rows: Any) -> pa.Table:
+    if isinstance(rows, slice) and rows == slice(None):
+        selected_rows = table
+    elif isinstance(rows, Sequence) and not rows:
+        selected_rows = table.slice(0, 0)
+    else:
+        range_ = convert_slice_to_nparray(num_rows=len(table), rows_slice=rows)
+        selected_rows = table.take(range_)
+    return selected_rows
+
+
+def convert_str_slice_to_int_slice(
+    str_slice: slice, columns: list[str]
+) -> tuple[int | None, int | None, int | None]:
+    start = columns.index(str_slice.start) if str_slice.start is not None else None
+    stop = columns.index(str_slice.stop) + 1 if str_slice.stop is not None else None
+    step = str_slice.step
+    return (start, stop, step)
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index 62067069..53481c2f 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -11,6 +11,7 @@ from typing import overload
 from narwhals._expression_parsing import evaluate_into_exprs
 from narwhals._pandas_like.expr import PandasLikeExpr
 from narwhals._pandas_like.utils import broadcast_series
+from narwhals._pandas_like.utils import convert_str_slice_to_int_slice
 from narwhals._pandas_like.utils import create_native_series
 from narwhals._pandas_like.utils import horizontal_concat
 from narwhals._pandas_like.utils import translate_dtype
@@ -22,6 +23,7 @@ from narwhals.dependencies import is_numpy_array
 from narwhals.utils import Implementation
 from narwhals.utils import flatten
 from narwhals.utils import generate_unique_token
+from narwhals.utils import is_sequence_but_not_str
 from narwhals.utils import parse_columns_to_drop
 
 if TYPE_CHECKING:
@@ -119,6 +121,9 @@ class PandasLikeDataFrame:
     @overload
     def __getitem__(self, item: slice) -> PandasLikeDataFrame: ...
 
+    @overload
+    def __getitem__(self, item: tuple[slice, slice]) -> Self: ...
+
     def __getitem__(
         self,
         item: str
@@ -126,7 +131,8 @@ class PandasLikeDataFrame:
         | slice
         | Sequence[int]
         | Sequence[str]
-        | tuple[Sequence[int], str | int],
+        | tuple[Sequence[int], str | int]
+        | tuple[slice, slice],
     ) -> PandasLikeSeries | PandasLikeDataFrame:
         if isinstance(item, str):
             from narwhals._pandas_like.series import PandasLikeSeries
@@ -140,16 +146,19 @@ class PandasLikeDataFrame:
         elif (
             isinstance(item, tuple)
             and len(item) == 2
-            and isinstance(item[1], (tuple, list))
+            and is_sequence_but_not_str(item[1])
         ):
+            if len(item[1]) == 0:
+                # Return empty dataframe
+                return self._from_native_frame(self._native_frame.__class__())
             if all(isinstance(x, int) for x in item[1]):
                 return self._from_native_frame(self._native_frame.iloc[item])
             if all(isinstance(x, str) for x in item[1]):
-                item = (
+                indexer = (
                     item[0],
                     self._native_frame.columns.get_indexer(item[1]),
                 )
-                return self._from_native_frame(self._native_frame.iloc[item])
+                return self._from_native_frame(self._native_frame.iloc[indexer])
             msg = (
                 f"Expected sequence str or int, got: {type(item[1])}"  # pragma: no cover
             )
@@ -158,15 +167,7 @@ class PandasLikeDataFrame:
         elif isinstance(item, tuple) and len(item) == 2 and isinstance(item[1], slice):
             columns = self._native_frame.columns
             if isinstance(item[1].start, str) or isinstance(item[1].stop, str):
-                start = (
-                    columns.get_loc(item[1].start) if item[1].start is not None else None
-                )
-                stop = (
-                    columns.get_loc(item[1].stop) + 1
-                    if item[1].stop is not None
-                    else None
-                )
-                step = item[1].step
+                start, stop, step = convert_str_slice_to_int_slice(item[1], columns)
                 return self._from_native_frame(
                     self._native_frame.iloc[item[0], slice(start, stop, step)]
                 )
@@ -197,13 +198,21 @@ class PandasLikeDataFrame:
                 backend_version=self._backend_version,
             )
 
-        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):
+        elif is_sequence_but_not_str(item) or (is_numpy_array(item) and item.ndim == 1):
+            if all(isinstance(x, str) for x in item) and len(item) > 0:
                 return self._from_native_frame(self._native_frame.loc[:, item])
             return self._from_native_frame(self._native_frame.iloc[item])
 
+        elif isinstance(item, slice):
+            if isinstance(item.start, str) or isinstance(item.stop, str):
+                start, stop, step = convert_str_slice_to_int_slice(
+                    item, self._native_frame.columns
+                )
+                return self._from_native_frame(
+                    self._native_frame.iloc[:, slice(start, stop, step)]
+                )
+            return self._from_native_frame(self._native_frame.iloc[item])
+
         else:  # pragma: no cover
             msg = f"Expected str or slice, got: {type(item)}"
             raise TypeError(msg)
diff --git a/narwhals/_pandas_like/utils.py b/narwhals/_pandas_like/utils.py
index 9e1d79ce..bdcb71ef 100644
--- a/narwhals/_pandas_like/utils.py
+++ b/narwhals/_pandas_like/utils.py
@@ -19,6 +19,7 @@ if TYPE_CHECKING:
     from narwhals.dtypes import DType
 
     ExprT = TypeVar("ExprT", bound=PandasLikeExpr)
+    import pandas as pd
 
 
 def validate_column_comparand(index: Any, other: Any) -> Any:
@@ -497,3 +498,12 @@ def int_dtype_mapper(dtype: Any) -> str:
     if str(dtype).lower() != str(dtype):  # pragma: no cover
         return "Int64"
     return "int64"
+
+
+def convert_str_slice_to_int_slice(
+    str_slice: slice, columns: pd.Index
+) -> tuple[int | None, int | None, int | None]:
+    start = columns.get_loc(str_slice.start) if str_slice.start is not None else None
+    stop = columns.get_loc(str_slice.stop) + 1 if str_slice.stop is not None else None
+    step = str_slice.step
+    return (start, stop, step)
diff --git a/narwhals/_polars/dataframe.py b/narwhals/_polars/dataframe.py
index c5164147..9f30382d 100644
--- a/narwhals/_polars/dataframe.py
+++ b/narwhals/_polars/dataframe.py
@@ -4,10 +4,12 @@ from typing import TYPE_CHECKING
 from typing import Any
 
 from narwhals._polars.namespace import PolarsNamespace
+from narwhals._polars.utils import convert_str_slice_to_int_slice
 from narwhals._polars.utils import extract_args_kwargs
 from narwhals._polars.utils import translate_dtype
 from narwhals.dependencies import get_polars
 from narwhals.utils import Implementation
+from narwhals.utils import is_sequence_but_not_str
 from narwhals.utils import parse_columns_to_drop
 
 if TYPE_CHECKING:
@@ -84,38 +86,52 @@ class PolarsDataFrame:
         return self._native_frame.shape  # type: ignore[no-any-return]
 
     def __getitem__(self, item: Any) -> Any:
-        if isinstance(item, tuple) and len(item) == 2 and isinstance(item[1], slice):
+        if self._backend_version >= (0, 20, 30):
+            return self._from_native_object(self._native_frame.__getitem__(item))
+        else:  # pragma: no cover
             # TODO(marco): we can delete this branch after Polars==0.20.30 becomes the minimum
             # Polars version we support
             columns = self.columns
-            if isinstance(item[1].start, str) or isinstance(item[1].stop, str):
-                start = (
-                    columns.index(item[1].start) if item[1].start is not None else None
-                )
-                stop = (
-                    columns.index(item[1].stop) + 1 if item[1].stop is not None else None
-                )
-                step = item[1].step
-                return self._from_native_frame(
-                    self._native_frame.select(columns[start:stop:step]).__getitem__(
-                        item[0]
+            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):
+                    start, stop, step = convert_str_slice_to_int_slice(item[1], columns)
+                    return self._from_native_frame(
+                        self._native_frame.select(columns[start:stop:step]).__getitem__(
+                            item[0]
+                        )
                     )
-                )
-            if isinstance(item[1].start, int) or isinstance(item[1].stop, int):
+                if isinstance(item[1].start, int) or isinstance(item[1].stop, int):
+                    return self._from_native_frame(
+                        self._native_frame.select(
+                            columns[item[1].start : item[1].stop : item[1].step]
+                        ).__getitem__(item[0])
+                    )
+                msg = f"Expected slice of integers or strings, got: {type(item[1])}"  # pragma: no cover
+                raise TypeError(msg)  # pragma: no cover
+            pl = get_polars()
+            if (
+                isinstance(item, tuple)
+                and (len(item) == 2)
+                and is_sequence_but_not_str(item[1])
+                and (len(item[1]) == 0)
+            ):
+                result = self._native_frame.select(item[1])
+            elif isinstance(item, slice) and (
+                isinstance(item.start, str) or isinstance(item.stop, str)
+            ):
+                start, stop, step = convert_str_slice_to_int_slice(item, columns)
                 return self._from_native_frame(
-                    self._native_frame.select(
-                        columns[item[1].start : item[1].stop : item[1].step]
-                    ).__getitem__(item[0])
+                    self._native_frame.select(columns[start:stop:step])
                 )
-            msg = f"Expected slice of integers or strings, got: {type(item[1])}"  # pragma: no cover
-            raise TypeError(msg)  # pragma: no cover
-        pl = get_polars()
-        result = self._native_frame.__getitem__(item)
-        if isinstance(result, pl.Series):
-            from narwhals._polars.series import PolarsSeries
-
-            return PolarsSeries(result, backend_version=self._backend_version)
-        return self._from_native_object(result)
+            elif is_sequence_but_not_str(item) and (len(item) == 0):
+                result = self._native_frame.slice(0, 0)
+            else:
+                result = self._native_frame.__getitem__(item)
+            if isinstance(result, pl.Series):
+                from narwhals._polars.series import PolarsSeries
+
+                return PolarsSeries(result, backend_version=self._backend_version)
+            return self._from_native_object(result)
 
     def get_column(self, name: str) -> Any:
         from narwhals._polars.series import PolarsSeries
diff --git a/narwhals/_polars/utils.py b/narwhals/_polars/utils.py
index 51f0b189..46f399a8 100644
--- a/narwhals/_polars/utils.py
+++ b/narwhals/_polars/utils.py
@@ -110,3 +110,12 @@ def narwhals_to_native_dtype(dtype: dtypes.DType | type[dtypes.DType]) -> Any:
     if dtype == dtypes.Date:
         return pl.Date()
     return pl.Unknown()  # pragma: no cover
+
+
+def convert_str_slice_to_int_slice(
+    str_slice: slice, columns: list[str]
+) -> tuple[int | None, int | None, int | None]:  # pragma: no cover
+    start = columns.index(str_slice.start) if str_slice.start is not None else None
+    stop = columns.index(str_slice.stop) + 1 if str_slice.stop is not None else None
+    step = str_slice.step
+    return (start, stop, step)
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index b51d53ba..72ecc36b 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -15,6 +15,7 @@ from narwhals.dependencies import get_polars
 from narwhals.dependencies import is_numpy_array
 from narwhals.schema import Schema
 from narwhals.utils import flatten
+from narwhals.utils import is_sequence_but_not_str
 from narwhals.utils import parse_version
 
 if TYPE_CHECKING:
@@ -624,6 +625,9 @@ class DataFrame(BaseFrame[FrameT]):
     @overload
     def __getitem__(self, item: slice) -> Self: ...
 
+    @overload
+    def __getitem__(self, item: tuple[slice, slice]) -> Self: ...
+
     def __getitem__(
         self,
         item: str
@@ -632,7 +636,8 @@ class DataFrame(BaseFrame[FrameT]):
         | Sequence[str]
         | tuple[Sequence[int], str | int]
         | tuple[slice, str | int]
-        | tuple[slice | Sequence[int], Sequence[int] | Sequence[str] | slice],
+        | tuple[slice | Sequence[int], Sequence[int] | Sequence[str] | slice]
+        | tuple[slice, slice],
     ) -> Series | Self:
         """
         Extract column or slice of DataFrame.
@@ -715,8 +720,12 @@ class DataFrame(BaseFrame[FrameT]):
         if (
             isinstance(item, tuple)
             and len(item) == 2
-            and isinstance(item[1], (list, tuple, slice))
+            and (is_sequence_but_not_str(item[1]) or isinstance(item[1], slice))
         ):
+            if item[1] == slice(None) and item[0] == slice(None):
+                return self
+            if item[1] == slice(None):
+                return self._from_compliant_dataframe(self._compliant_frame[item[0]])
             return self._from_compliant_dataframe(self._compliant_frame[item])
         if isinstance(item, str) or (isinstance(item, tuple) and len(item) == 2):
             from narwhals.series import Series
@@ -726,8 +735,10 @@ class DataFrame(BaseFrame[FrameT]):
                 level=self._level,
             )
 
-        elif isinstance(item, (Sequence, slice)) or (
-            is_numpy_array(item) and item.ndim == 1
+        elif (
+            is_sequence_but_not_str(item)
+            or isinstance(item, slice)
+            or (is_numpy_array(item) and item.ndim == 1)
         ):
             return self._from_compliant_dataframe(self._compliant_frame[item])
 
diff --git a/narwhals/stable/v1.py b/narwhals/stable/v1.py
index fa98fd96..1f103aae 100644
--- a/narwhals/stable/v1.py
+++ b/narwhals/stable/v1.py
@@ -103,6 +103,9 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
     @overload
     def __getitem__(self, item: slice) -> Self: ...
 
+    @overload
+    def __getitem__(self, item: tuple[slice, slice]) -> Self: ...
+
     def __getitem__(self, item: Any) -> Any:
         return _stableify(super().__getitem__(item))
 
diff --git a/narwhals/utils.py b/narwhals/utils.py
index ec3c722d..f9d34099 100644
--- a/narwhals/utils.py
+++ b/narwhals/utils.py
@@ -33,6 +33,7 @@ if TYPE_CHECKING:
     from types import ModuleType
 
     from typing_extensions import Self
+    from typing_extensions import TypeGuard
 
     from narwhals.dataframe import BaseFrame
     from narwhals.series import Series
@@ -461,3 +462,7 @@ def parse_columns_to_drop(
     else:
         to_drop = list(cols.intersection(set(to_drop)))
     return to_drop
+
+
+def is_sequence_but_not_str(sequence: Any) -> TypeGuard[Sequence[Any]]:
+    return isinstance(sequence, Sequence) and not isinstance(sequence, str)


Test patch:
diff --git a/tests/frame/slice_test.py b/tests/frame/slice_test.py
index 0867844f..a832daa6 100644
--- a/tests/frame/slice_test.py
+++ b/tests/frame/slice_test.py
@@ -116,7 +116,7 @@ def test_slice_int_rows_str_columns(constructor_eager: Any) -> None:
     compare_dicts(result, expected)
 
 
-def test_slice_slice_columns(constructor_eager: Any) -> None:
+def test_slice_slice_columns(constructor_eager: Any) -> None:  # noqa: PLR0915
     data = {"a": [1, 2, 3], "b": [4, 5, 6], "c": [7, 8, 9], "d": [1, 4, 2]}
     df = nw.from_native(constructor_eager(data), eager_only=True)
     result = df[[0, 1], "b":"c"]  # type: ignore[misc]
@@ -152,9 +152,29 @@ def test_slice_slice_columns(constructor_eager: Any) -> None:
     result = df[:2, [0, 2]]
     expected = {"a": [1, 2], "c": [7, 8]}
     compare_dicts(result, expected)
+    result = df[:2, ["a", "c"]]
+    expected = {"a": [1, 2], "c": [7, 8]}
+    compare_dicts(result, expected)
+    result = df[1:, [0, 2]]
+    expected = {"a": [2, 3], "c": [8, 9]}
+    compare_dicts(result, expected)
+    result = df[1:, ["a", "c"]]
+    expected = {"a": [2, 3], "c": [8, 9]}
+    compare_dicts(result, expected)
     result = df[["b", "c"]]
     expected = {"b": [4, 5, 6], "c": [7, 8, 9]}
     compare_dicts(result, expected)
+    result = df[:2]
+    expected = {"a": [1, 2], "b": [4, 5], "c": [7, 8], "d": [1, 4]}
+    compare_dicts(result, expected)
+    result = df[2:]
+    expected = {"a": [3], "b": [6], "c": [9], "d": [2]}
+    compare_dicts(result, expected)
+    # mypy says "Slice index must be an integer", but we do in fact support
+    # using string slices
+    result = df["a":"b"]  # type: ignore[misc]
+    expected = {"a": [1, 2, 3], "b": [4, 5, 6]}
+    compare_dicts(result, expected)
 
 
 def test_slice_invalid(constructor_eager: Any) -> None:
@@ -162,3 +182,14 @@ def test_slice_invalid(constructor_eager: Any) -> None:
     df = nw.from_native(constructor_eager(data), eager_only=True)
     with pytest.raises(TypeError, match="Hint:"):
         df[0, 0]
+
+
+def test_slice_edge_cases(constructor_eager: Any) -> None:
+    data = {"a": [1, 2, 3], "b": [4, 5, 6], "c": [7, 8, 9], "d": [1, 4, 2]}
+    df = nw.from_native(constructor_eager(data), eager_only=True)
+    assert df[[], :].shape == (0, 4)
+    assert df[:, []].shape == (0, 0)
+    assert df[[]].shape == (0, 4)
+    assert df[[], ["a"]].shape == (0, 1)
+    assert df[:, :].shape == (3, 4)
+    assert df[[], []].shape == (0, 0)
diff --git a/tests/series_only/slice_test.py b/tests/series_only/slice_test.py
index 48cf15bc..9ae19477 100644
--- a/tests/series_only/slice_test.py
+++ b/tests/series_only/slice_test.py
@@ -25,3 +25,9 @@ def test_slice(constructor_eager: Any) -> None:
     result = {"b": df[:2, 1]}
     expected = {"b": [4, 5]}
     compare_dicts(result, expected)
+    result = {"b": df[[0, 1], 1]}
+    expected = {"b": [4, 5]}
+    compare_dicts(result, expected)
+    result = {"b": df[[], 1]}
+    expected = {"b": []}
+    compare_dicts(result, expected)