Repository: narwhals-dev/narwhals

Issue:
docs: include PyArrow in Tutorial/DataFrame
In https://narwhals-dev.github.io/narwhals/basics/dataframe/ we only show pandas + Polars. Let's get PyArrow in there too?

I'd suggest:
- for all except for "example 3", we could have tabs for: pandas, Polars[lazy], PyArrow
- for example 3, we could have: pandas, Polars[eager], PyArrow

---

file to modify: https://github.com/narwhals-dev/narwhals/blob/main/docs/basics/dataframe.md

contributing guide: https://github.com/narwhals-dev/narwhals/blob/main/CONTRIBUTING.md

Discussion:

Patch:
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/extending.md b/docs/extending.md
index 13b93387..f6829ba3 100644
--- a/docs/extending.md
+++ b/docs/extending.md
@@ -4,11 +4,13 @@
 
 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
diff --git a/docs/installation.md b/docs/installation.md
index ac7d2341..2f801254 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -28,6 +28,7 @@ they are not required dependencies - Narwhals only ever uses what the user passe
 
 - [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
 
@@ -38,6 +39,7 @@ 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
 
@@ -48,13 +50,19 @@ def my_function(df_native: IntoFrame) -> list[str]:
     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]})
+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
diff --git a/docs/roadmap_and_related.md b/docs/roadmap_and_related.md
index 5e464683..43580db6 100644
--- a/docs/roadmap_and_related.md
+++ b/docs/roadmap_and_related.md
@@ -23,3 +23,9 @@ 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/narwhals/dataframe.py b/narwhals/dataframe.py
index 16b869ff..7ac21698 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -395,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:
 
@@ -410,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
@@ -422,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()
 
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 1f103aae..1cd73125 100644
--- a/narwhals/stable/v1.py
+++ b/narwhals/stable/v1.py
@@ -342,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/pyproject.toml b/pyproject.toml
index 0196124f..f2109e11 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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


Test patch:
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__)