Repository: narwhals-dev/narwhals
Pull request #403 (2024-07-04 14:39:06)

fix: `dtype` could not be passed to Series.__array__
<!--
# 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/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 66a3aa1b..35641247 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -62,6 +62,9 @@ class ArrowSeries:
     def to_list(self) -> Any:
         return self._series.to_pylist()
 
+    def __array__(self, dtype: Any = None, copy: bool | None = None) -> Any:
+        return self._series.__array__(dtype=dtype, copy=copy)
+
     def to_numpy(self) -> Any:
         return self._series.to_numpy()
 
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index 001c899a..abfd49fb 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -431,7 +431,17 @@ class PandasSeries:
         ser = self._series
         return self._from_series(self._rename(ser, name))
 
-    def to_numpy(self) -> Any:
+    def __array__(self, dtype: Any = None, copy: bool | None = None) -> Any:
+        # pandas used to always return object dtype for nullable dtypes.
+        # So, we intercept __array__ and pass to `to_numpy` ourselves to make
+        # sure an appropriate numpy dtype is returned.
+        return self.to_numpy(dtype=dtype, copy=copy)
+
+    def to_numpy(self, dtype: Any = None, copy: bool | None = None) -> Any:
+        # the default is meant to be None, but pandas doesn't allow it?
+        # https://numpy.org/doc/stable/reference/generated/numpy.ndarray.__array__.html
+        copy = copy or False
+
         has_missing = self._series.isna().any()
         if has_missing and str(self._series.dtype) in PANDAS_TO_NUMPY_DTYPE_MISSING:
             if self._implementation == "pandas" and parse_version(
@@ -441,16 +451,19 @@ class PandasSeries:
             else:
                 kwargs = {"na_value": float("nan")}
             return self._series.to_numpy(
-                dtype=PANDAS_TO_NUMPY_DTYPE_MISSING[str(self._series.dtype)], **kwargs
+                dtype=dtype or PANDAS_TO_NUMPY_DTYPE_MISSING[str(self._series.dtype)],
+                copy=copy,
+                **kwargs,
             )
         if (
             not has_missing
             and str(self._series.dtype) in PANDAS_TO_NUMPY_DTYPE_NO_MISSING
         ):
             return self._series.to_numpy(
-                dtype=PANDAS_TO_NUMPY_DTYPE_NO_MISSING[str(self._series.dtype)]
+                dtype=dtype or PANDAS_TO_NUMPY_DTYPE_NO_MISSING[str(self._series.dtype)],
+                copy=copy,
             )
-        return self._series.to_numpy()
+        return self._series.to_numpy(dtype=dtype, copy=copy)
 
     def to_pandas(self) -> Any:
         if self._implementation == "pandas":
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index 2882d874..239fcd2c 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -245,8 +245,8 @@ class DataFrame(BaseFrame):
             msg = f"Expected pandas-like dataframe, Polars dataframe, or Polars lazyframe, got: {type(df)}"
             raise TypeError(msg)
 
-    def __array__(self, *args: Any, **kwargs: Any) -> np.ndarray:
-        return self._dataframe.to_numpy(*args, **kwargs)
+    def __array__(self) -> np.ndarray:
+        return self._dataframe.to_numpy()
 
     def __repr__(self) -> str:  # pragma: no cover
         header = " Narwhals DataFrame                            "
@@ -637,8 +637,8 @@ class DataFrame(BaseFrame):
 
             We define a library agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
+            >>> @nw.narwhalify
+            ... def func(df):
             ...     return df.columns
 
             We can then pass either pandas or Polars to `func`:
@@ -1168,20 +1168,19 @@ class DataFrame(BaseFrame):
             >>> import pandas as pd
             >>> import polars as pl
             >>> import narwhals as nw
-            >>> df = {
+            >>> data = {
             ...     "foo": [1, 2, 3, 1],
             ...     "bar": ["a", "a", "a", "a"],
             ...     "ham": ["b", "b", "b", "b"],
             ... }
-            >>> df_pd = pd.DataFrame(df)
-            >>> df_pl = pl.DataFrame(df)
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
 
             We define a library agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.unique(["bar", "ham"])
-            ...     return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.unique(["bar", "ham"])
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1401,65 +1400,32 @@ class DataFrame(BaseFrame):
                          sequence of booleans.
 
         Examples:
-            Pass a single column name to sort by that column.
-
-            >>> import polars as pl
             >>> import narwhals as nw
-            >>> df_pl = pl.DataFrame(
-            ...     {
-            ...         "a": [1, 2, None],
-            ...         "b": [6.0, 5.0, 4.0],
-            ...         "c": ["a", "c", "b"],
-            ...     }
-            ... )
-            >>> df = nw.from_native(df_pl, eager_only=True)
-            >>> dframe = df.sort("a")
-            >>> dframe
-            ┌───────────────────────────────────────────────┐
-            | Narwhals DataFrame                            |
-            | Use `narwhals.to_native` to see native output |
-            └───────────────────────────────────────────────┘
-            >>> nw.to_native(dframe)
-            shape: (3, 3)
-            ┌──────┬─────┬─────┐
-            │ a    ┆ b   ┆ c   │
-            │ ---  ┆ --- ┆ --- │
-            │ i64  ┆ f64 ┆ str │
-            ╞══════╪═════╪═════╡
-            │ null ┆ 4.0 ┆ b   │
-            │ 1    ┆ 6.0 ┆ a   │
-            │ 2    ┆ 5.0 ┆ c   │
-            └──────┴─────┴─────┘
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> data = {
+            ...     "a": [1, 2, None],
+            ...     "b": [6.0, 5.0, 4.0],
+            ...     "c": ["a", "c", "b"],
+            ... }
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
 
-            Sort by multiple columns by passing a list of columns.
+            Let's define a dataframe-agnostic function in which we sort by multiple
+            columns in different orders
 
-            >>> dframe = df.sort(["c", "a"], descending=True)
-            >>> dframe
-            ┌───────────────────────────────────────────────┐
-            | Narwhals DataFrame                            |
-            | Use `narwhals.to_native` to see native output |
-            └───────────────────────────────────────────────┘
-            >>> nw.to_native(dframe)
-            shape: (3, 3)
-            ┌──────┬─────┬─────┐
-            │ a    ┆ b   ┆ c   │
-            │ ---  ┆ --- ┆ --- │
-            │ i64  ┆ f64 ┆ str │
-            ╞══════╪═════╪═════╡
-            │ 2    ┆ 5.0 ┆ c   │
-            │ null ┆ 4.0 ┆ b   │
-            │ 1    ┆ 6.0 ┆ a   │
-            └──────┴─────┴─────┘
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.sort("c", "a", descending=[False, True])
 
-            Or use positional arguments to sort by multiple columns in the same way.
+            We can then pass either pandas or Polars to `func`:
 
-            >>> dframe = df.sort("c", "a", descending=[False, True])
-            >>> dframe
-            ┌───────────────────────────────────────────────┐
-            | Narwhals DataFrame                            |
-            | Use `narwhals.to_native` to see native output |
-            └───────────────────────────────────────────────┘
-            >>> nw.to_native(dframe)
+            >>> func(df_pd)
+                 a    b  c
+            0  1.0  6.0  a
+            2  NaN  4.0  b
+            1  2.0  5.0  c
+            >>> func(df_pl)
             shape: (3, 3)
             ┌──────┬─────┬─────┐
             │ a    ┆ b   ┆ c   │
@@ -1820,8 +1786,8 @@ class LazyFrame(BaseFrame):
             DataFrame
 
         Examples:
-            >>> import polars as pl
             >>> import narwhals as nw
+            >>> import polars as pl
             >>> lf_pl = pl.LazyFrame(
             ...     {
             ...         "a": ["a", "b", "a", "b", "b", "c"],
@@ -1836,11 +1802,6 @@ class LazyFrame(BaseFrame):
             | Use `narwhals.to_native` to see native output |
             └───────────────────────────────────────────────┘
             >>> df = lf.group_by("a").agg(nw.all().sum()).collect()
-            >>> df
-            ┌───────────────────────────────────────────────┐
-            | Narwhals DataFrame                            |
-            | Use `narwhals.to_native` to see native output |
-            └───────────────────────────────────────────────┘
             >>> nw.to_native(df).sort("a")
             shape: (3, 3)
             ┌─────┬─────┬─────┐
@@ -2005,19 +1966,25 @@ class LazyFrame(BaseFrame):
         Get column names.
 
         Examples:
-
+            >>> import pandas as pd
             >>> import polars as pl
             >>> import narwhals as nw
-            >>> lf_pl = pl.LazyFrame(
-            ...     {
-            ...         "foo": [1, 2, 3],
-            ...         "bar": [6, 7, 8],
-            ...         "ham": ["a", "b", "c"],
-            ...     }
-            ... ).select("foo", "bar")
-            >>> lf = nw.from_native(lf_pl)
-            >>> lf.columns  # doctest: +SKIP
-            ['foo', 'bar']
+            >>> df = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
+            >>> df_pd = pd.DataFrame(df)
+            >>> lf_pl = pl.LazyFrame(df)
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.columns
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(df_pd)
+            ['foo', 'bar', 'ham']
+            >>> func(lf_pl)  # doctest: +SKIP
+            ['foo', 'bar', 'ham']
         """
         return super().columns
 
@@ -2282,23 +2249,27 @@ class LazyFrame(BaseFrame):
              pushdowns at this node.
 
         Examples:
+            >>> import pandas as pd
             >>> import polars as pl
             >>> import narwhals as nw
-            >>> lf_pl = pl.LazyFrame(
-            ...     {
-            ...         "foo": [1, 2, 3],
-            ...         "bar": [6, 7, 8],
-            ...         "ham": ["a", "b", "c"],
-            ...     }
-            ... )
-            >>> lf = nw.from_native(lf_pl)
-            >>> lframe = lf.rename({"foo": "apple"}).collect()
-            >>> lframe
-            ┌───────────────────────────────────────────────┐
-            | Narwhals DataFrame                            |
-            | Use `narwhals.to_native` to see native output |
-            └───────────────────────────────────────────────┘
-            >>> nw.to_native(lframe)
+            >>> data = {"foo": [1, 2, 3], "bar": [6, 7, 8], "ham": ["a", "b", "c"]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> lf_pl = pl.LazyFrame(data)
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.rename({"foo": "apple"})
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(df_pd)
+               apple  bar ham
+            0      1    6   a
+            1      2    7   b
+            2      3    8   c
+            >>> func(lf_pl).collect()
             shape: (3, 3)
             ┌───────┬─────┬─────┐
             │ apple ┆ bar ┆ ham │
@@ -2504,40 +2475,29 @@ class LazyFrame(BaseFrame):
             LazyFrame: LazyFrame with unique rows.
 
         Examples:
+            >>> import pandas as pd
             >>> import polars as pl
             >>> import narwhals as nw
-            >>> lf_pl = pl.LazyFrame(
-            ...     {
-            ...         "foo": [1, 2, 3, 1],
-            ...         "bar": ["a", "a", "a", "a"],
-            ...         "ham": ["b", "b", "b", "b"],
-            ...     }
-            ... )
-            >>> lf = nw.from_native(lf_pl)
-            >>> lframe = lf.unique(None).collect().sort("foo")
-            >>> lframe
-            ┌───────────────────────────────────────────────┐
-            | Narwhals DataFrame                            |
-            | Use `narwhals.to_native` to see native output |
-            └───────────────────────────────────────────────┘
-            >>> nw.to_native(lframe)
-            shape: (3, 3)
-            ┌─────┬─────┬─────┐
-            │ foo ┆ bar ┆ ham │
-            │ --- ┆ --- ┆ --- │
-            │ i64 ┆ str ┆ str │
-            ╞═════╪═════╪═════╡
-            │ 1   ┆ a   ┆ b   │
-            │ 2   ┆ a   ┆ b   │
-            │ 3   ┆ a   ┆ b   │
-            └─────┴─────┴─────┘
-            >>> lframe = lf.unique(subset=["bar", "ham"]).collect().sort("foo")
-            >>> lframe
-            ┌───────────────────────────────────────────────┐
-            | Narwhals DataFrame                            |
-            | Use `narwhals.to_native` to see native output |
-            └───────────────────────────────────────────────┘
-            >>> nw.to_native(lframe)
+            >>> data = {
+            ...     "foo": [1, 2, 3, 1],
+            ...     "bar": ["a", "a", "a", "a"],
+            ...     "ham": ["b", "b", "b", "b"],
+            ... }
+            >>> df_pd = pd.DataFrame(data)
+            >>> lf_pl = pl.LazyFrame(data)
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.unique(["bar", "ham"])
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(df_pd)
+               foo bar ham
+            0    1   a   b
+            >>> func(lf_pl).collect()
             shape: (1, 3)
             ┌─────┬─────┬─────┐
             │ foo ┆ bar ┆ ham │
@@ -2818,65 +2778,32 @@ class LazyFrame(BaseFrame):
                          sequence of booleans.
 
         Examples:
-            Pass a single column name to sort by that column.
-
-            >>> import polars as pl
             >>> import narwhals as nw
-            >>> lf_pl = pl.LazyFrame(
-            ...     {
-            ...         "a": [1, 2, None],
-            ...         "b": [6.0, 5.0, 4.0],
-            ...         "c": ["a", "c", "b"],
-            ...     }
-            ... )
-            >>> lf = nw.from_native(lf_pl)
-            >>> lframe = lf.sort("a").collect()
-            >>> lframe
-            ┌───────────────────────────────────────────────┐
-            | Narwhals DataFrame                            |
-            | Use `narwhals.to_native` to see native output |
-            └───────────────────────────────────────────────┘
-            >>> nw.to_native(lframe)
-            shape: (3, 3)
-            ┌──────┬─────┬─────┐
-            │ a    ┆ b   ┆ c   │
-            │ ---  ┆ --- ┆ --- │
-            │ i64  ┆ f64 ┆ str │
-            ╞══════╪═════╪═════╡
-            │ null ┆ 4.0 ┆ b   │
-            │ 1    ┆ 6.0 ┆ a   │
-            │ 2    ┆ 5.0 ┆ c   │
-            └──────┴─────┴─────┘
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> data = {
+            ...     "a": [1, 2, None],
+            ...     "b": [6.0, 5.0, 4.0],
+            ...     "c": ["a", "c", "b"],
+            ... }
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_lf = pl.LazyFrame(data)
 
-            Sort by multiple columns by passing a list of columns.
+            Let's define a dataframe-agnostic function in which we sort by multiple
+            columns in different orders
 
-            >>> lframe = lf.sort(["c", "a"], descending=True).collect()
-            >>> lframe
-            ┌───────────────────────────────────────────────┐
-            | Narwhals DataFrame                            |
-            | Use `narwhals.to_native` to see native output |
-            └───────────────────────────────────────────────┘
-            >>> nw.to_native(lframe)
-            shape: (3, 3)
-            ┌──────┬─────┬─────┐
-            │ a    ┆ b   ┆ c   │
-            │ ---  ┆ --- ┆ --- │
-            │ i64  ┆ f64 ┆ str │
-            ╞══════╪═════╪═════╡
-            │ 2    ┆ 5.0 ┆ c   │
-            │ null ┆ 4.0 ┆ b   │
-            │ 1    ┆ 6.0 ┆ a   │
-            └──────┴─────┴─────┘
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.sort("c", "a", descending=[False, True])
 
-            Or use positional arguments to sort by multiple columns in the same way.
+            We can then pass either pandas or Polars to `func`:
 
-            >>> lframe = lf.sort("c", "a", descending=[False, True]).collect()
-            >>> lframe
-            ┌───────────────────────────────────────────────┐
-            | Narwhals DataFrame                            |
-            | Use `narwhals.to_native` to see native output |
-            └───────────────────────────────────────────────┘
-            >>> nw.to_native(lframe)
+            >>> func(df_pd)
+                 a    b  c
+            0  1.0  6.0  a
+            2  NaN  4.0  b
+            1  2.0  5.0  c
+            >>> func(df_lf).collect()
             shape: (3, 3)
             ┌──────┬─────┬─────┐
             │ a    ┆ b   ┆ c   │
diff --git a/narwhals/series.py b/narwhals/series.py
index cc0941b3..a5411bf7 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -75,8 +75,8 @@ class Series:
         )
         raise TypeError(msg)  # pragma: no cover
 
-    def __array__(self, *args: Any, **kwargs: Any) -> np.ndarray:
-        return self._series.to_numpy(*args, **kwargs)
+    def __array__(self, dtype: Any = None, copy: bool | None = None) -> np.ndarray:
+        return self._series.__array__(dtype=dtype, copy=copy)
 
     def __getitem__(self, idx: int | slice) -> Any:
         if isinstance(idx, int):
diff --git a/pyproject.toml b/pyproject.toml
index 90c9a24e..97716126 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -92,6 +92,7 @@ exclude_also = [
   "if sys.version_info() <",
   "if implementation == \"modin\"",
   "if implementation == \"cudf\"",
+  'request.applymarker\(pytest.mark.xfail\)'
 ]
 
 [tool.mypy]


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 00b9274c..9b7fc942 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -112,8 +112,12 @@ def constructor_series(request: Any) -> Callable[[Any], Any]:
     return request.param  # type: ignore[no-any-return]
 
 
+def pyarrow_chunked_array_constructor(obj: Any) -> Any:
+    return pa.chunked_array([obj])
+
+
 # TODO: once pyarrow has complete coverage, we can remove this one,
 # and just put `pa.table` into `constructor`
-@pytest.fixture(params=[*params_series, lambda x: pa.chunked_array([x])])
+@pytest.fixture(params=[*params_series, pyarrow_chunked_array_constructor])
 def constructor_series_with_pyarrow(request: Any) -> Callable[[Any], Any]:
     return request.param  # type: ignore[no-any-return]
diff --git a/tests/series/array_dunder_test.py b/tests/series/array_dunder_test.py
new file mode 100644
index 00000000..a9d3546a
--- /dev/null
+++ b/tests/series/array_dunder_test.py
@@ -0,0 +1,23 @@
+from typing import Any
+
+import numpy as np
+import polars as pl
+import pyarrow as pa
+import pytest
+
+import narwhals as nw
+from narwhals.utils import parse_version
+
+
+def test_array_dunder(constructor_series_with_pyarrow: Any, request: Any) -> None:
+    if "polars" in str(constructor_series_with_pyarrow) and parse_version(
+        pl.__version__
+    ) < parse_version("0.20.10"):
+        request.applymarker(pytest.mark.xfail)
+    if "chunked_array" in str(constructor_series_with_pyarrow) and parse_version(
+        pa.__version__
+    ) < parse_version("16.0.0"):
+        request.applymarker(pytest.mark.xfail)
+    s = nw.from_native(constructor_series_with_pyarrow([1, 2, 3]), series_only=True)
+    result = s.__array__(object)
+    np.testing.assert_array_equal(result, np.array([1, 2, 3], dtype=object))