Skip to content

Commit 277881a

Browse files
authored
feat: Add Series.from_numpy (#2893)
1 parent 22fdf75 commit 277881a

File tree

7 files changed

+238
-7
lines changed

7 files changed

+238
-7
lines changed

docs/api-reference/series.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
- exp
3030
- fill_null
3131
- filter
32+
- from_numpy
3233
- gather_every
3334
- head
3435
- hist

narwhals/dtypes.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,17 @@
77
from itertools import starmap
88
from typing import TYPE_CHECKING
99

10-
from narwhals._utils import _DeferredIterable, isinstance_or_issubclass
10+
from narwhals._utils import (
11+
_DeferredIterable,
12+
isinstance_or_issubclass,
13+
qualified_type_name,
14+
)
1115

1216
if TYPE_CHECKING:
1317
from collections.abc import Iterator, Sequence
18+
from typing import Any
1419

15-
from typing_extensions import Self
20+
from typing_extensions import Self, TypeIs
1621

1722
from narwhals.typing import IntoDType, TimeUnit
1823

@@ -26,6 +31,34 @@ def _validate_dtype(dtype: DType | type[DType]) -> None:
2631
raise TypeError(msg)
2732

2833

34+
def _is_into_dtype(obj: Any) -> TypeIs[IntoDType]:
35+
return isinstance(obj, DType) or (
36+
isinstance(obj, type)
37+
and issubclass(obj, DType)
38+
and not issubclass(obj, NestedType)
39+
)
40+
41+
42+
def _is_nested_type(obj: Any) -> TypeIs[type[NestedType]]:
43+
return isinstance(obj, type) and issubclass(obj, NestedType)
44+
45+
46+
def _validate_into_dtype(dtype: Any) -> None:
47+
if not _is_into_dtype(dtype):
48+
if _is_nested_type(dtype):
49+
name = f"nw.{dtype.__name__}"
50+
msg = (
51+
f"{name!r} is not valid in this context.\n\n"
52+
f"Hint: instead of:\n\n"
53+
f" {name}\n\n"
54+
"use:\n\n"
55+
f" {name}(...)"
56+
)
57+
else:
58+
msg = f"Expected Narwhals dtype, got: {qualified_type_name(dtype)!r}."
59+
raise TypeError(msg)
60+
61+
2962
class DType:
3063
def __repr__(self) -> str: # pragma: no cover
3164
return self.__class__.__qualname__

narwhals/series.py

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,21 @@
22

33
import math
44
from collections.abc import Iterator, Mapping, Sequence
5-
from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, overload
5+
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generic, Literal, overload
66

77
from narwhals._utils import (
88
Implementation,
9+
Version,
910
_validate_rolling_arguments,
1011
ensure_type,
1112
generate_repr,
1213
is_compliant_series,
14+
is_eager_allowed,
1315
is_index_selector,
1416
supports_arrow_c_stream,
1517
)
16-
from narwhals.dependencies import is_numpy_scalar
17-
from narwhals.dtypes import _validate_dtype
18+
from narwhals.dependencies import is_numpy_array_1d, is_numpy_scalar
19+
from narwhals.dtypes import _validate_dtype, _validate_into_dtype
1820
from narwhals.exceptions import ComputeError
1921
from narwhals.series_cat import SeriesCatNamespace
2022
from narwhals.series_dt import SeriesDateTimeNamespace
@@ -70,6 +72,8 @@ class Series(Generic[IntoSeriesT]):
7072
```
7173
"""
7274

75+
_version: ClassVar[Version] = Version.MAIN
76+
7377
@property
7478
def _dataframe(self) -> type[DataFrame[Any]]:
7579
from narwhals.dataframe import DataFrame
@@ -88,6 +92,74 @@ def __init__(
8892
msg = f"Expected Polars Series or an object which implements `__narwhals_series__`, got: {type(series)}."
8993
raise AssertionError(msg)
9094

95+
@classmethod
96+
def from_numpy(
97+
cls,
98+
name: str,
99+
values: _1DArray,
100+
dtype: IntoDType | None = None,
101+
*,
102+
backend: ModuleType | Implementation | str,
103+
) -> Series[Any]:
104+
"""Construct a Series from a NumPy ndarray.
105+
106+
Arguments:
107+
name: Name of resulting Series.
108+
values: One-dimensional data represented as a NumPy ndarray.
109+
dtype: (Narwhals) dtype. If not provided, the native library
110+
may auto-infer it from `values`.
111+
backend: specifies which eager backend instantiate to.
112+
113+
`backend` can be specified in various ways
114+
115+
- As `Implementation.<BACKEND>` with `BACKEND` being `PANDAS`, `PYARROW`,
116+
`POLARS`, `MODIN` or `CUDF`.
117+
- As a string: `"pandas"`, `"pyarrow"`, `"polars"`, `"modin"` or `"cudf"`.
118+
- Directly as a module `pandas`, `pyarrow`, `polars`, `modin` or `cudf`.
119+
120+
Returns:
121+
A new Series
122+
123+
Examples:
124+
>>> import numpy as np
125+
>>> import polars as pl
126+
>>> import narwhals as nw
127+
>>>
128+
>>> arr = np.arange(5, 10)
129+
>>> nw.Series.from_numpy("arr", arr, dtype=nw.Int8, backend="polars")
130+
┌──────────────────┐
131+
| Narwhals Series |
132+
|------------------|
133+
|shape: (5,) |
134+
|Series: 'arr' [i8]|
135+
|[ |
136+
| 5 |
137+
| 6 |
138+
| 7 |
139+
| 8 |
140+
| 9 |
141+
|] |
142+
└──────────────────┘
143+
"""
144+
if not is_numpy_array_1d(values):
145+
msg = "`from_numpy` only accepts 1D numpy arrays"
146+
raise ValueError(msg)
147+
if dtype:
148+
_validate_into_dtype(dtype)
149+
implementation = Implementation.from_backend(backend)
150+
if is_eager_allowed(implementation):
151+
ns = cls._version.namespace.from_backend(implementation).compliant
152+
compliant = ns.from_numpy(values).alias(name)
153+
if dtype:
154+
return cls(compliant.cast(dtype), level="full")
155+
return cls(compliant, level="full")
156+
msg = (
157+
f"{implementation} support in Narwhals is lazy-only, but `Series.from_numpy` is an eager-only function.\n\n"
158+
"Hint: you may want to use an eager backend and then call `.lazy`, e.g.:\n\n"
159+
f" nw.Series.from_numpy(arr, backend='pyarrow').to_frame().lazy('{implementation}')"
160+
)
161+
raise ValueError(msg)
162+
91163
@property
92164
def implementation(self) -> Implementation:
93165
"""Return implementation of native Series.

narwhals/stable/v1/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,8 @@ def with_row_index(
287287

288288

289289
class Series(NwSeries[IntoSeriesT]):
290+
_version = Version.V1
291+
290292
@inherit_doc(NwSeries)
291293
def __init__(
292294
self, series: Any, *, level: Literal["full", "lazy", "interchange"]
@@ -297,6 +299,18 @@ def __init__(
297299
# We need to override any method which don't return Self so that type
298300
# annotations are correct.
299301

302+
@classmethod
303+
def from_numpy(
304+
cls,
305+
name: str,
306+
values: _1DArray,
307+
dtype: IntoDType | None = None,
308+
*,
309+
backend: ModuleType | Implementation | str,
310+
) -> Series[Any]:
311+
result = super().from_numpy(name, values, dtype, backend=backend)
312+
return cast("Series[Any]", result)
313+
300314
@property
301315
def _dataframe(self) -> type[DataFrame[Any]]:
302316
return DataFrame
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from __future__ import annotations
2+
3+
import re
4+
from typing import TYPE_CHECKING, Any, cast
5+
6+
import pytest
7+
8+
pytest.importorskip("numpy")
9+
import numpy as np
10+
11+
import narwhals as nw
12+
from tests.utils import assert_equal_data
13+
14+
if TYPE_CHECKING:
15+
from collections.abc import Sequence
16+
17+
from narwhals._namespace import EagerAllowed
18+
from narwhals.dtypes import NestedType
19+
from narwhals.typing import IntoDType, _1DArray
20+
21+
22+
arr: _1DArray = cast("_1DArray", np.array([5, 2, 0, 1]))
23+
NAME = "a"
24+
25+
26+
def assert_equal_series(
27+
result: nw.Series[Any], expected: Sequence[Any], name: str
28+
) -> None:
29+
assert_equal_data(result.to_frame(), {name: expected})
30+
31+
32+
def test_series_from_numpy(eager_backend: EagerAllowed) -> None:
33+
expected = [5, 2, 0, 1]
34+
result = nw.Series.from_numpy(NAME, arr, backend=eager_backend)
35+
assert isinstance(result, nw.Series)
36+
assert_equal_series(result, expected, NAME)
37+
38+
39+
@pytest.mark.parametrize(
40+
("dtype", "expected"),
41+
[
42+
(nw.Int16, [5, 2, 0, 1]),
43+
(nw.Int32(), [5, 2, 0, 1]),
44+
(nw.Float64, [5.0, 2.0, 0.0, 1.0]),
45+
(nw.Float32(), [5.0, 2.0, 0.0, 1.0]),
46+
],
47+
ids=str,
48+
)
49+
def test_series_from_numpy_dtype(
50+
eager_backend: EagerAllowed, dtype: IntoDType, expected: Sequence[Any]
51+
) -> None:
52+
result = nw.Series.from_numpy(NAME, arr, backend=eager_backend, dtype=dtype)
53+
assert result.dtype == dtype
54+
assert_equal_series(result, expected, NAME)
55+
56+
57+
@pytest.mark.parametrize(
58+
("bad_dtype", "message"),
59+
[
60+
(nw.List, r"nw.List.+not.+valid.+hint"),
61+
(nw.Struct, r"nw.Struct.+not.+valid.+hint"),
62+
(nw.Array, r"nw.Array.+not.+valid.+hint"),
63+
(np.floating, r"expected.+narwhals.+dtype.+floating"),
64+
(list[int], r"expected.+narwhals.+dtype.+(types.GenericAlias|list)"),
65+
],
66+
ids=str,
67+
)
68+
def test_series_from_numpy_not_init_dtype(
69+
eager_backend: EagerAllowed, bad_dtype: type[NestedType] | object, message: str
70+
) -> None:
71+
with pytest.raises(TypeError, match=re.compile(message, re.IGNORECASE | re.DOTALL)):
72+
nw.Series.from_numpy(NAME, arr, bad_dtype, backend=eager_backend) # type: ignore[arg-type]
73+
74+
75+
def test_series_from_numpy_not_eager() -> None:
76+
pytest.importorskip("ibis")
77+
with pytest.raises(ValueError, match="lazy-only"):
78+
nw.Series.from_numpy(NAME, arr, backend="ibis")
79+
80+
81+
def test_series_from_numpy_not_1d(eager_backend: EagerAllowed) -> None:
82+
with pytest.raises(ValueError, match="`from_numpy` only accepts 1D numpy arrays"):
83+
nw.Series.from_numpy(NAME, np.array([[0], [2]]), backend=eager_backend) # pyright: ignore[reportArgumentType]

tests/v1_test.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,13 @@
4040
)
4141

4242
if TYPE_CHECKING:
43+
from collections.abc import Sequence
44+
4345
from typing_extensions import assert_type
4446

4547
from narwhals._namespace import EagerAllowed
4648
from narwhals.stable.v1.typing import IntoDataFrameT
47-
from narwhals.typing import _2DArray
49+
from narwhals.typing import IntoDType, _1DArray, _2DArray
4850
from tests.utils import Constructor, ConstructorEager
4951

5052

@@ -615,7 +617,8 @@ def test_series_recursive_v1() -> None:
615617

616618
pl_series = pl.Series(name="test", values=[1, 2, 3])
617619
nw_series = nw_v1.from_native(pl_series, series_only=True)
618-
with pytest.raises(AttributeError):
620+
# NOTE: (#2629) combined with passing in `nw_v1.Series` (w/ a `_version`) into itself changes the error
621+
with pytest.raises(AssertionError):
619622
nw_v1.Series(nw_series, level="full")
620623

621624
nw_series_early_return = nw_v1.from_native(nw_series, series_only=True)
@@ -1022,3 +1025,27 @@ def test_dataframe_from_numpy(eager_backend: EagerAllowed) -> None:
10221025
assert isinstance(result_schema, nw_v1.Schema)
10231026

10241027
assert isinstance(result_schema, nw.Schema)
1028+
1029+
1030+
@pytest.mark.parametrize(
1031+
("dtype", "expected"),
1032+
[
1033+
(None, [5, 2, 0, 1]),
1034+
(nw_v1.Int64, [5, 2, 0, 1]),
1035+
(nw_v1.Int16(), [5, 2, 0, 1]),
1036+
(nw_v1.Float64, [5.0, 2.0, 0.0, 1.0]),
1037+
(nw_v1.Float32(), [5.0, 2.0, 0.0, 1.0]),
1038+
],
1039+
ids=str,
1040+
)
1041+
def test_series_from_numpy(
1042+
eager_backend: EagerAllowed, dtype: IntoDType | None, expected: Sequence[Any]
1043+
) -> None:
1044+
arr: _1DArray = cast("_1DArray", np.array([5, 2, 0, 1]))
1045+
name = "abc"
1046+
result = nw_v1.Series.from_numpy(name, arr, backend=eager_backend, dtype=dtype)
1047+
assert result._version is Version.V1
1048+
assert isinstance(result, nw_v1.Series)
1049+
if dtype:
1050+
assert result.dtype == dtype
1051+
assert_equal_data(result.to_frame(), {name: expected})

utils/check_api_reference.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ def read_documented_members(source: str | Path) -> list[str]:
6666
"arg_min",
6767
"arg_true",
6868
"dtype",
69+
"from_numpy",
6970
"gather_every",
7071
"implementation",
7172
"is_empty",

0 commit comments

Comments
 (0)