Repository: narwhals-dev/narwhals
Pull request #995 (2024-09-19 02:27:26)

test: add specific check for cuDF for test_to_datetime
<!--
# 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
- [x] ✅ Test
- [ ] 🐳 Other

## Related issues 

- Related issue # https://github.com/narwhals-dev/narwhals/issues/862
- 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.

Currently this test fails with 
```
FAILED tests/expr_and_series/str/to_datetime_test.py::test_to_datetime[cudf_constructor] - AssertionError: assert '2020-01-01T1...:56.000000000' == '2020-01-01 12:34:56'
```
I created these in the native libraries and I see they have different data types and render differently when converted back to a string.
```
import pandas as pd
import cudf

data = {"a": ["2020-01-01T12:34:56"]}

df_cudf = pd.DataFrame(data)
df_pandas = pd.DataFrame(data)

df_pandas_dt = pd.to_datetime(df_pandas.a, format="%Y-%m-%dT%H:%M:%S")
df_cudf_dt = cudf.to_datetime(df_cudf.a, format="%Y-%m-%dT%H:%M:%S")

print(df_pandas_dt[0])
print(type(df_pandas_dt[0]))
print(df_cudf_dt[0])
print(type(df_cudf_dt[0]))
```

```
2020-01-01 12:34:56
<class 'pandas._libs.tslibs.timestamps.Timestamp'>
2020-01-01T12:34:56.000000000
<class 'numpy.datetime64'>
```

Not sure though if this is an acceptable difference and something to just account for in the tests like this though. 



Patch:
diff --git a/.github/workflows/check_docs_build.yml b/.github/workflows/check_docs_build.yml
index c59e67f4..1602bff4 100644
--- a/.github/workflows/check_docs_build.yml
+++ b/.github/workflows/check_docs_build.yml
@@ -19,7 +19,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install uv
-        uses: astral-sh/setup-uv@v2
+        uses: astral-sh/setup-uv@v3
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
diff --git a/.github/workflows/check_tpch_queries.yml b/.github/workflows/check_tpch_queries.yml
index 509ebb95..f1bbde92 100644
--- a/.github/workflows/check_tpch_queries.yml
+++ b/.github/workflows/check_tpch_queries.yml
@@ -19,7 +19,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install uv
-        uses: astral-sh/setup-uv@v2
+        uses: astral-sh/setup-uv@v3
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
diff --git a/.github/workflows/extremes.yml b/.github/workflows/extremes.yml
index cec9d32d..f11a4f4b 100644
--- a/.github/workflows/extremes.yml
+++ b/.github/workflows/extremes.yml
@@ -19,7 +19,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install uv
-        uses: astral-sh/setup-uv@v2
+        uses: astral-sh/setup-uv@v3
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
@@ -46,7 +46,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install uv
-        uses: astral-sh/setup-uv@v2
+        uses: astral-sh/setup-uv@v3
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
@@ -75,7 +75,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install uv
-        uses: astral-sh/setup-uv@v2
+        uses: astral-sh/setup-uv@v3
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
@@ -104,7 +104,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install uv
-        uses: astral-sh/setup-uv@v2
+        uses: astral-sh/setup-uv@v3
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml
index 37b18841..354dafd4 100644
--- a/.github/workflows/mkdocs.yml
+++ b/.github/workflows/mkdocs.yml
@@ -27,6 +27,6 @@ jobs:
           path: .cache
           restore-keys: |
             mkdocs-material-
-      - run: pip install -r docs/requirements-docs.txt -e . pandas polars
+      - run: pip install -r docs/requirements-docs.txt -e . pandas polars pyarrow
 
       - run: mkdocs gh-deploy --force
diff --git a/.gitignore b/.gitignore
index 3825a68a..8b9adeb8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,5 +32,8 @@ tpch/data/*
 # VSCode
 .vscode/
 
+# IntelliJ IDEA / PyCharm
+.idea/
+
 # MacOS
 .DS_Store
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index aeed2538..25051474 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -91,7 +91,8 @@ If you add code that should be tested, please add tests.
 ### 6. Running tests
 
 To run tests, run `pytest`. To check coverage: `pytest --cov=narwhals`.
-To run tests on the docset-module, use `pytest narwhals --doctest-modules`.
+To run tests on the doctests, use `pytest narwhals --doctest-modules`.
+To run unit tests and doctests at the same time, run `pytest tests narwhals --cov=narwhals --doctest-modules`.
 
 If you want to have less surprises when opening a PR, you can take advantage of [nox](https://nox.thea.codes/en/stable/index.html) to run the entire CI/CD test suite locally in your operating system.
 
@@ -153,7 +154,7 @@ listed above in [Working with local development environment](#working-with-local
 ## How it works
 
 If Narwhals looks like underwater unicorn magic to you, then please read
-[how it works](https://narwhals-dev.github.io/narwhals/how-it-works/).
+[how it works](https://narwhals-dev.github.io/narwhals/how_it_works/).
 
 ## Imports
 
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/api-reference/series.md b/docs/api-reference/series.md
index c016b566..9868e7b9 100644
--- a/docs/api-reference/series.md
+++ b/docs/api-reference/series.md
@@ -57,6 +57,7 @@
         - to_list
         - to_numpy
         - to_pandas
+        - to_native
         - unique
         - value_counts
         - zip_with
diff --git a/docs/basics/dataframe.md b/docs/basics/dataframe.md
index 3cd2c884..41be8aeb 100644
--- a/docs/basics/dataframe.md
+++ b/docs/basics/dataframe.md
@@ -4,7 +4,7 @@ To write a dataframe-agnostic function, the steps you'll want to follow are:
 
 1. Initialise a Narwhals DataFrame or LazyFrame by passing your dataframe to `nw.from_native`.
     All the calculations stay lazy if we start with a lazy dataframe - Narwhals will never automatically trigger computation without you asking it to.
-   
+
     Note: if you need eager execution, make sure to pass `eager_only=True` to `nw.from_native`.
 
 2. Express your logic using the subset of the Polars API supported by Narwhals.
@@ -21,6 +21,7 @@ Just like in Polars, we can pass expressions to
 `DataFrame.select` or `LazyFrame.select`.
 
 Make a Python file with the following content:
+
 ```python exec="1" source="above" session="df_ex1"
 import narwhals as nw
 from narwhals.typing import FrameT
@@ -34,6 +35,7 @@ def func(df: FrameT) -> FrameT:
         a_std=nw.col("a").std(),
     )
 ```
+
 Let's try it out:
 
 === "pandas"
@@ -60,7 +62,16 @@ Let's try it out:
     print(func(df).collect())
     ```
 
+=== "PyArrow"
+    ```python exec="true" source="material-block" result="python" session="df_ex1"
+    import pyarrow as pa
+
+    table = pa.table({"a": [1, 1, 2]})
+    print(func(table))
+    ```
+
 Alternatively, we could have opted for the more explicit version:
+
 ```python
 import narwhals as nw
 from narwhals.typing import IntoFrameT
@@ -75,6 +86,7 @@ def func(df_native: IntoFrameT) -> IntoFrameT:
     )
     return nw.to_native(df)
 ```
+
 Despite being more verbose, it has the advantage of preserving the type annotation of the native
 object - see [typing](../api-reference/typing.md) for more details.
 
@@ -84,6 +96,7 @@ In general, in this tutorial, we'll use the former.
 
 Just like in Polars, we can pass expressions to `GroupBy.agg`.
 Make a Python file with the following content:
+
 ```python exec="1" source="above" session="df_ex2"
 import narwhals as nw
 from narwhals.typing import FrameT
@@ -93,6 +106,7 @@ from narwhals.typing import FrameT
 def func(df: FrameT) -> FrameT:
     return df.group_by("a").agg(nw.col("b").mean()).sort("a")
 ```
+
 Let's try it out:
 
 === "pandas"
@@ -119,12 +133,21 @@ Let's try it out:
     print(func(df).collect())
     ```
 
+=== "PyArrow"
+    ```python exec="true" source="material-block" result="python" session="df_ex2"
+    import pyarrow as pa
+
+    table = pa.table({"a": [1, 1, 2], "b": [4, 5, 6]})
+    print(func(table))
+    ```
+
 ## Example 3: horizontal sum
 
 Expressions can be free-standing functions which accept other expressions as inputs.
 For example, we can compute a horizontal sum using `nw.sum_horizontal`.
 
 Make a Python file with the following content:
+
 ```python exec="1" source="above" session="df_ex3"
 import narwhals as nw
 from narwhals.typing import FrameT
@@ -134,6 +157,7 @@ from narwhals.typing import FrameT
 def func(df: FrameT) -> FrameT:
     return df.with_columns(a_plus_b=nw.sum_horizontal("a", "b"))
 ```
+
 Let's try it out:
 
 === "pandas"
@@ -160,6 +184,14 @@ Let's try it out:
     print(func(df).collect())
     ```
 
+=== "PyArrow"
+    ```python exec="true" source="material-block" result="python" session="df_ex3"
+    import pyarrow as pa
+
+    table = pa.table({"a": [1, 1, 2], "b": [4, 5, 6]})
+    print(func(table))
+    ```
+
 ## Example 4: multiple inputs
 
 `nw.narwhalify` can be used to decorate functions that take multiple inputs as well and
@@ -169,6 +201,7 @@ For example, let's compute how many rows are left in a dataframe after filtering
 on a series.
 
 Make a Python file with the following content:
+
 ```python exec="1" source="above" session="df_ex4"
 from typing import Any
 
@@ -201,3 +234,12 @@ Let's try it out:
     s = pl.Series([1, 3])
     print(func(df, s.to_numpy(), "a"))
     ```
+
+=== "PyArrow"
+    ```python exec="true" source="material-block" result="python" session="df_ex4"
+    import pyarrow as pa
+
+    table = pa.table({"a": [1, 1, 2, 2, 3], "b": [4, 5, 6, 7, 8]})
+    a = pa.array([1, 3])
+    print(func(table, a.to_numpy(), "a"))
+    ```
diff --git a/docs/basics/column.md b/docs/basics/series.md
similarity index 84%
rename from docs/basics/column.md
rename to docs/basics/series.md
index 62951cb4..f5d8dee1 100644
--- a/docs/basics/column.md
+++ b/docs/basics/series.md
@@ -47,6 +47,14 @@ def my_func(df: FrameT) -> FrameT:
     print(my_func(df).collect())
     ```
 
+=== "PyArrow"
+    ```python exec="true" source="material-block" result="python" session="ex1"
+    import pyarrow as pa
+
+    table = pa.table({"a": [-1, 1, 3], "b": [3, 5, -3]})
+    print(my_func(table))
+    ```
+
 ## Example 2: multiply a column's values by a constant
 
 Let's write a dataframe-agnostic function which multiplies the values in column
@@ -86,6 +94,14 @@ def my_func(df: FrameT) -> FrameT:
     print(my_func(df).collect())
     ```
 
+=== "PyArrow"
+    ```python exec="true" source="material-block" result="python" session="ex2"
+    import pyarrow as pa
+
+    table = pa.table({"a": [-1, 1, 3], "b": [3, 5, -3]})
+    print(my_func(table))
+    ```
+
 Note that column `'a'` was overwritten. If we had wanted to add a new column called `'c'` containing column `'a'`'s
 values multiplied by 2, we could have used `Expr.alias`:
 
@@ -123,6 +139,14 @@ def my_func(df: FrameT) -> FrameT:
     print(my_func(df).collect())
     ```
 
+=== "PyArrow"
+    ```python exec="true" source="material-block" result="python" session="ex2.1"
+    import pyarrow as pa
+
+    table = pa.table({"a": [-1, 1, 3], "b": [3, 5, -3]})
+    print(my_func(table))
+    ```
+
 ## Example 3: finding the mean of a column as a scalar
 
 Now, we want to find the mean of column `'a'`, and we need it as a Python scalar.
@@ -156,5 +180,13 @@ def my_func(df: nw.DataFrame) -> float | None:
     print(my_func(df))
     ```
 
+=== "PyArrow"
+    ```python exec="true" source="material-block" result="python" session="ex2"
+    import pyarrow as pa
+
+    table = pa.table({"a": [-1, 1, 3], "b": [3, 5, -3]})
+    print(my_func(table))
+    ```
+
 Note that, even though the output of our function is not a dataframe nor a series, we can
 still use `narwhalify`.
diff --git a/docs/extending.md b/docs/extending.md
index 1a750431..f6829ba3 100644
--- a/docs/extending.md
+++ b/docs/extending.md
@@ -1,12 +1,16 @@
-# 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:
 
-- pandas
-- Polars
-- cuDF
-- Modin
-- PyArrow
+| Library  | 🔗 Link 🔗 |
+| ------------- | ------------- |
+| ️Polars 🐻‍❄️ | [github.com/pola-rs/polars](https://github.com/pola-rs/polars) |
+| pandas 🐼 |  [github.com/pandas-dev/pandas](https://github.com/pandas-dev/pandas) |
+| cuDF | [github.com/rapidsai/cudf](https://github.com/rapidsai/cudf) |
+| Modin | [github.com/modin-project/modin](https://github.com/modin-project/modin) |
+| PyArrow ⇶ | [arrow.apache.org/docs/python](https://arrow.apache.org/docs/python/index.html) |
 
 If you want your own library to be recognised too, you're welcome open a PR (with tests)!
 Alternatively, if you can't do that (for example, if you library is closed-source), see
@@ -44,3 +48,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/generate_members.py b/docs/generate_members.py
deleted file mode 100644
index 85492d7d..00000000
--- a/docs/generate_members.py
+++ /dev/null
@@ -1,43 +0,0 @@
-# ruff: noqa
-# type: ignore
-import sys
-
-sys.path.append("..")
-
-import pandas as pd
-import polars as pl
-
-pd_series = pd.Series([1], name="a").__column_consortium_standard__()
-pl_series = pl.Series("a", [1]).__column_consortium_standard__()
-pd_df = pd.DataFrame({"a": [1]}).__dataframe_consortium_standard__()
-pl_df = pl.DataFrame({"a": [1]}).__dataframe_consortium_standard__()
-pd_scalar = pd_df.col("a").mean()
-pl_scalar = pl_df.col("a").mean()
-pd_namespace = pd_df.__dataframe_namespace__()
-pl_namespace = pl_df.__dataframe_namespace__()
-
-for name, object in [
-    ("pandas-column.md", pd_series),
-    ("polars-column.md", pl_series),
-    ("pandas-dataframe.md", pd_df),
-    ("polars-dataframe.md", pl_df),
-    ("pandas-scalar.md", pd_scalar),
-    ("polars-scalar.md", pl_scalar),
-    ("pandas-namespace.md", pd_scalar),
-    ("polars-namespace.md", pl_scalar),
-]:
-    members = [
-        i for i in object.__dir__() if not (i.startswith("_") and not i.startswith("__"))
-    ]
-
-    with open(name) as fd:
-        content = fd.read()
-
-    members_txt = "\n      - ".join(sorted(members)) + "\n      "
-
-    start = content.index("members:")
-    end = content.index("show_signature")
-    content = content[:start] + f"members:\n      - {members_txt}" + content[end:]
-
-    with open(name, "w") as fd:
-        fd.write(content)
diff --git a/docs/installation.md b/docs/installation.md
index 61760681..2f801254 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.
 
@@ -11,6 +13,60 @@ Then, if you start the Python REPL and see the following:
 ```python
 >>> import narwhals
 >>> narwhals.__version__
-'1.8.1'
+'1.8.2'
 ```
 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/)
+- [PyArrow](https://arrow.apache.org/docs/python/install.html)
+
+### 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 pyarrow as pa
+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
+
+
+data = {"a": [1, 2, 3], "b": [4, 5, 6]}
+df_pandas = pd.DataFrame(data)
+df_polars = pl.DataFrame(data)
+table_pa = pa.table(data)
+
+print("pandas output")
+print(my_function(df_pandas))
+
+print("Polars output")
+print(my_function(df_polars))
+
+print("PyArrow output")
+print(my_function(table_pa))
+```
+
+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..43580db6
--- /dev/null
+++ b/docs/roadmap_and_related.md
@@ -0,0 +1,31 @@
+# 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).
+
+### PyCapsule Interface
+
+Allows C extension modules to safely share pointers to C data structures with Python code and other C modules, encapsulating the pointer with a name and optional destructor to manage resources and ensure safe access, see [here](https://arrow.apache.org/docs/format/CDataInterface/PyCapsuleInterface.html) for details. 
+
+Narwhals supports exporting a DataFrame via the Arrow PyCapsule Interface.
diff --git a/mkdocs.yml b/mkdocs.yml
index 8b635f78..045a6a67 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -5,28 +5,25 @@ 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
+    - basics/series.md
     - basics/complete_example.md
   - Pandas-like concepts:
     - 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/__init__.py b/narwhals/__init__.py
index 3c8b7b77..efc5cd8c 100644
--- a/narwhals/__init__.py
+++ b/narwhals/__init__.py
@@ -53,7 +53,7 @@ from narwhals.utils import maybe_convert_dtypes
 from narwhals.utils import maybe_get_index
 from narwhals.utils import maybe_set_index
 
-__version__ = "1.8.1"
+__version__ = "1.8.2"
 
 __all__ = [
     "dependencies",
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..6f61853f 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..21c9c80b 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:
@@ -394,14 +395,16 @@ class DataFrame(BaseFrame[FrameT]):
         Convert this DataFrame to a pandas DataFrame.
 
         Examples:
-            Construct pandas and Polars DataFrames:
+            Construct pandas, Polars (eager) and PyArrow DataFrames:
 
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
             >>> import narwhals as nw
             >>> df = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
             >>> df_pd = pd.DataFrame(df)
             >>> df_pl = pl.DataFrame(df)
+            >>> df_pa = pa.table(df)
 
             We define a library agnostic function:
 
@@ -409,7 +412,7 @@ class DataFrame(BaseFrame[FrameT]):
             ... def func(df):
             ...     return df.to_pandas()
 
-            We can then pass either pandas or Polars to `func`:
+            We can then pass any supported library such as pandas, Polars (eager), or PyArrow to `func`:
 
             >>> func(df_pd)
                foo  bar ham
@@ -421,6 +424,13 @@ class DataFrame(BaseFrame[FrameT]):
             0    1  6.0   a
             1    2  7.0   b
             2    3  8.0   c
+            >>> func(df_pa)
+               foo  bar ham
+            0    1  6.0   a
+            1    2  7.0   b
+            2    3  8.0   c
+
+
         """
         return self._compliant_frame.to_pandas()
 
@@ -433,10 +443,12 @@ class DataFrame(BaseFrame[FrameT]):
 
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
             >>> import narwhals as nw
             >>> df = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
             >>> df_pd = pd.DataFrame(df)
             >>> df_pl = pl.DataFrame(df)
+            >>> df_pa = pa.table(df)
 
             We define a library agnostic function:
 
@@ -444,12 +456,14 @@ class DataFrame(BaseFrame[FrameT]):
             ...     df = nw.from_native(df)
             ...     return df.write_csv()
 
-            We can then pass either pandas or Polars to `func`:
+            We can pass any supported library such as pandas, Polars or PyArrow to `func`:
 
             >>> func(df_pd)  # doctest: +SKIP
             'foo,bar,ham\n1,6.0,a\n2,7.0,b\n3,8.0,c\n'
             >>> func(df_pl)  # doctest: +SKIP
             'foo,bar,ham\n1,6.0,a\n2,7.0,b\n3,8.0,c\n'
+            >>> func(df_pa)  # doctest: +SKIP
+            'foo,bar,ham\n1,6.0,a\n2,7.0,b\n3,8.0,c\n'
 
             If we had passed a file name to `write_csv`, it would have been
             written to that file.
@@ -624,6 +638,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 +649,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 +733,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 +748,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])
 
@@ -1035,10 +1059,12 @@ class DataFrame(BaseFrame[FrameT]):
         Examples:
             >>> import pandas as pd
             >>> import polars as pl
+            >>> import pyarrow as pa
             >>> import narwhals as nw
             >>> df = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
             >>> df_pd = pd.DataFrame(df)
             >>> df_pl = pl.DataFrame(df)
+            >>> df_pa = pa.table(df)
 
             We define a library agnostic function:
 
@@ -1046,12 +1072,14 @@ class DataFrame(BaseFrame[FrameT]):
             ... def func(df):
             ...     return df.columns
 
-            We can then pass either pandas or Polars to `func`:
+            We can pass any supported library such as pandas, Polars, or PyArrow to `func`:
 
             >>> func(df_pd)
             ['foo', 'bar', 'ham']
             >>> func(df_pl)
             ['foo', 'bar', 'ham']
+            >>> func(df_pa)
+            ['foo', 'bar', 'ham']
         """
         return super().columns
 
diff --git a/narwhals/series.py b/narwhals/series.py
index 9d21058f..f34217c9 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -22,7 +22,7 @@ class Series:
     """
     Narwhals Series, backed by a native series.
 
-    The native dataframe might be pandas.Series, polars.Series, ...
+    The native series might be pandas.Series, polars.Series, ...
 
     This class is not meant to be instantiated directly - instead, use
     `narwhals.from_native`, making sure to pass `allow_series=True` or
@@ -85,6 +85,45 @@ class Series:
         ca = pa.chunked_array([self.to_arrow()])
         return ca.__arrow_c_stream__(requested_schema=requested_schema)
 
+    def to_native(self) -> Any:
+        """
+        Convert Narwhals series to native series.
+
+        Returns:
+            Series of class that user started with.
+
+        Examples:
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import narwhals as nw
+            >>> s = [1, 2, 3]
+            >>> s_pd = pd.Series(s)
+            >>> s_pl = pl.Series(s)
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(s):
+            ...     return s.to_native()
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(s_pd)
+            0    1
+            1    2
+            2    3
+            dtype: int64
+            >>> func(s_pl)  # doctest: +NORMALIZE_WHITESPACE
+            shape: (3,)
+            Series: '' [i64]
+            [
+                1
+                2
+                3
+            ]
+        """
+        return self._compliant_series._native_series
+
     def scatter(self, indices: int | Sequence[int], values: Any) -> Self:
         """
         Set value(s) at given position(s).
@@ -224,7 +263,7 @@ class Series:
             + "─" * length
             + "┐\n"
             + f"|{header}|\n"
-            + "| Use `narwhals.to_native()` to see native output |\n"
+            + "| Use `series.to_native()` to see native output |\n"
             + "└"
             + "─" * length
             + "┘"
diff --git a/narwhals/stable/v1.py b/narwhals/stable/v1.py
index fa98fd96..1cd73125 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))
 
@@ -339,7 +342,7 @@ class Series(NwSeries):
     """
     Narwhals Series, backed by a native series.
 
-    The native dataframe might be pandas.Series, polars.Series, ...
+    The native series might be pandas.Series, polars.Series, ...
 
     This class is not meant to be instantiated directly - instead, use
     `narwhals.from_native`, making sure to pass `allow_series=True` or
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)
diff --git a/pyproject.toml b/pyproject.toml
index efc02f9b..f2109e11 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "narwhals"
-version = "1.8.1"
+version = "1.8.2"
 authors = [
   { name="Marco Gorelli", email="33491632+MarcoGorelli@users.noreply.github.com" },
 ]
@@ -99,6 +99,7 @@ force-single-line = true
 docstring-code-format = true
 
 [tool.pytest.ini_options]
+testpaths = ["tests"]
 filterwarnings = [
   "error",
   'ignore:distutils Version classes are deprecated:DeprecationWarning',
diff --git a/utils/check_api_reference.py b/utils/check_api_reference.py
index 1bf1f086..0f95dfed 100644
--- a/utils/check_api_reference.py
+++ b/utils/check_api_reference.py
@@ -27,6 +27,7 @@ SERIES_ONLY_METHODS = {
     "zip_with",
     "item",
     "scatter",
+    "to_native",
 }
 
 # TODO(Unassigned): make dtypes reference page as well
diff --git a/utils/generate_backend_completeness.py b/utils/generate_backend_completeness.py
index b2cb9df2..1d6ee417 100644
--- a/utils/generate_backend_completeness.py
+++ b/utils/generate_backend_completeness.py
@@ -38,6 +38,8 @@ BACKENDS = [
 
 EXCLUDE_CLASSES = {"BaseFrame", "Then", "When"}
 
+DIRECTLY_IMPLEMENTED_METHODS = ["pipe"]
+
 
 def get_class_methods(kls: type[Any]) -> list[str]:
     return [m[0] for m in inspect.getmembers(kls) if not m[0].startswith("_")]
@@ -50,7 +52,9 @@ def parse_module(module_name: str, backend: str, nw_class_name: str) -> list[str
             module_,
             predicate=lambda c: inspect.isclass(c) and c.__name__.endswith(nw_class_name),
         )
-        methods_ = get_class_methods(class_[0][1]) if class_ else []
+        methods_ = (
+            get_class_methods(class_[0][1]) if class_ else []
+        ) + DIRECTLY_IMPLEMENTED_METHODS
 
     except ModuleNotFoundError:
         methods_ = []


Test patch:
diff --git a/.github/workflows/downstream_tests.yml b/.github/workflows/downstream_tests.yml
index 26e9edf7..0dcb7820 100644
--- a/.github/workflows/downstream_tests.yml
+++ b/.github/workflows/downstream_tests.yml
@@ -19,7 +19,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install uv
-        uses: astral-sh/setup-uv@v2
+        uses: astral-sh/setup-uv@v3
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
@@ -63,7 +63,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install uv
-        uses: astral-sh/setup-uv@v2
+        uses: astral-sh/setup-uv@v3
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index 39b5c91b..179891dd 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -19,7 +19,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install uv
-        uses: astral-sh/setup-uv@v2
+        uses: astral-sh/setup-uv@v3
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
@@ -46,7 +46,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install uv
-        uses: astral-sh/setup-uv@v2
+        uses: astral-sh/setup-uv@v3
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
@@ -75,7 +75,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install uv
-        uses: astral-sh/setup-uv@v2
+        uses: astral-sh/setup-uv@v3
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
diff --git a/.github/workflows/random_ci_pytest.yml b/.github/workflows/random_ci_pytest.yml
index b029aba3..addc73ed 100644
--- a/.github/workflows/random_ci_pytest.yml
+++ b/.github/workflows/random_ci_pytest.yml
@@ -17,7 +17,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install uv
-        uses: astral-sh/setup-uv@v2
+        uses: astral-sh/setup-uv@v3
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
diff --git a/tests/expr_and_series/str/to_datetime_test.py b/tests/expr_and_series/str/to_datetime_test.py
index ad666aa8..4eb76846 100644
--- a/tests/expr_and_series/str/to_datetime_test.py
+++ b/tests/expr_and_series/str/to_datetime_test.py
@@ -5,6 +5,11 @@ data = {"a": ["2020-01-01T12:34:56"]}
 
 
 def test_to_datetime(constructor: Constructor) -> None:
+    if "cudf" in str(constructor):  # pragma: no cover
+        expected = "2020-01-01T12:34:56.000000000"
+    else:
+        expected = "2020-01-01 12:34:56"
+
     result = (
         nw.from_native(constructor(data))
         .lazy()
@@ -12,4 +17,4 @@ def test_to_datetime(constructor: Constructor) -> None:
         .collect()
         .item(row=0, column="b")
     )
-    assert str(result) == "2020-01-01 12:34:56"
+    assert str(result) == expected
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)
diff --git a/tests/series_only/to_native_test.py b/tests/series_only/to_native_test.py
new file mode 100644
index 00000000..269348ea
--- /dev/null
+++ b/tests/series_only/to_native_test.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+from typing import Any
+
+import narwhals.stable.v1 as nw
+
+data = [4, 4, 4, 1, 6, 6, 4, 4, 1, 1]
+
+
+def test_to_native(constructor_eager: Any) -> None:
+    orig_series = constructor_eager({"a": data})["a"]
+    nw_series = nw.from_native(constructor_eager({"a": data}), eager_only=True)["a"]
+    result = nw_series.to_native()
+    assert isinstance(result, orig_series.__class__)