Skip to content
9 changes: 5 additions & 4 deletions narwhals/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
from narwhals.typing import IntoSeriesT
from narwhals.typing import NativeFrame
from narwhals.typing import NativeLazyFrame
from narwhals.typing import NativeSeries
from narwhals.typing import _2DArray

_IntoSchema: TypeAlias = "Mapping[str, DType] | Schema | Sequence[str] | None"
Expand Down Expand Up @@ -240,7 +241,7 @@ def _new_series_impl(
else: # pragma: no cover
native_namespace = implementation.to_native_namespace()
try:
native_series = native_namespace.new_series(name, values, dtype)
native_series: NativeSeries = native_namespace.new_series(name, values, dtype)
return from_native(native_series, series_only=True).alias(name)
except AttributeError as e:
msg = "Unknown namespace is expected to implement `new_series` constructor."
Expand Down Expand Up @@ -325,7 +326,7 @@ def _from_dict_impl(
try:
# implementation is UNKNOWN, Narwhals extension using this feature should
# implement `from_dict` function in the top-level namespace.
native_frame = native_namespace.from_dict(data, schema=schema)
native_frame: NativeFrame = native_namespace.from_dict(data, schema=schema)
except AttributeError as e:
msg = "Unknown namespace is expected to implement `from_dict` function."
raise AttributeError(msg) from e
Expand Down Expand Up @@ -441,7 +442,7 @@ def _from_numpy_impl(
try:
# implementation is UNKNOWN, Narwhals extension using this feature should
# implement `from_numpy` function in the top-level namespace.
native_frame = native_namespace.from_numpy(data, schema=schema)
native_frame: NativeFrame = native_namespace.from_numpy(data, schema=schema)
except AttributeError as e:
msg = "Unknown namespace is expected to implement `from_numpy` function."
raise AttributeError(msg) from e
Expand Down Expand Up @@ -529,7 +530,7 @@ def _from_arrow_impl(
try:
# implementation is UNKNOWN, Narwhals extension using this feature should
# implement PyCapsule support
native_frame = native_namespace.DataFrame(data)
native_frame: NativeFrame = native_namespace.DataFrame(data)
except AttributeError as e:
msg = "Unknown namespace is expected to implement `DataFrame` class which accepts object which supports PyCapsule Interface."
raise AttributeError(msg) from e
Expand Down
11 changes: 10 additions & 1 deletion narwhals/stable/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
from narwhals.typing import _2DArray

FrameT = TypeVar("FrameT", "DataFrame[Any]", "LazyFrame[Any]")
SeriesT = TypeVar("SeriesT", bound="Series[Any]")
IntoSeriesT = TypeVar("IntoSeriesT", bound="IntoSeries", default=Any)
T = TypeVar("T", default=Any)
else:
Expand Down Expand Up @@ -1154,6 +1155,10 @@ def _stableify(
return obj


@overload
def from_native(native_object: SeriesT, **kwds: Any) -> SeriesT: ...


@overload
def from_native(
native_object: IntoDataFrameT | IntoSeriesT,
Expand Down Expand Up @@ -1540,7 +1545,7 @@ def from_native(
) -> Any: ...


def from_native(
def from_native( # noqa: D417
native_object: IntoFrameT | IntoFrame | IntoSeriesT | IntoSeries | T,
*,
strict: bool | None = None,
Expand All @@ -1549,6 +1554,7 @@ def from_native(
eager_or_interchange_only: bool = False,
series_only: bool = False,
allow_series: bool | None = None,
**kwds: Any,
) -> LazyFrame[IntoFrameT] | DataFrame[IntoFrameT] | Series[IntoSeriesT] | T:
"""Convert `native_object` to Narwhals Dataframe, Lazyframe, or Series.

Expand Down Expand Up @@ -1608,6 +1614,9 @@ def from_native(
pass_through = validate_strict_and_pass_though(
strict, pass_through, pass_through_default=False, emit_deprecation_warning=False
)
if kwds:
msg = f"from_native() got an unexpected keyword argument {next(iter(kwds))!r}"
raise TypeError(msg)

result = _from_native_impl(
native_object,
Expand Down
11 changes: 10 additions & 1 deletion narwhals/translate.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
from narwhals.typing import IntoLazyFrameT
from narwhals.typing import IntoSeries
from narwhals.typing import IntoSeriesT
from narwhals.typing import SeriesT

T = TypeVar("T")

Expand Down Expand Up @@ -128,6 +129,10 @@ def to_native(
return narwhals_object


@overload
def from_native(native_object: SeriesT, **kwds: Any) -> SeriesT: ...
Comment on lines +132 to +133
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But why does this work?

By having an unconditional @overload when a nw.Series is passed - it ensures no other @overload(s) are checked.

If we were to spell out all the valid cases, they would overlap with the NativeSeries cases (see #2347 (comment))

narwhals.from_native(native_series, allow_series=True)
narwhals.from_native(native_series, series_only=True)

Needing to have **kwds is unfortunate - but

practicality beats purity

I'm open to any other solutions that can pass the new tests πŸ™‚

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shall we raise at runtime if kwds?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shall we raise at runtime if kwds?

Yeah I was thinking about that as well!

I wasn't sure

  • what the message should be?
  • could that logic be shared for both v1 and main?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.



@overload
def from_native(
native_object: IntoDataFrameT | IntoSeries,
Expand Down Expand Up @@ -298,14 +303,15 @@ def from_native(
) -> Any: ...


def from_native(
def from_native( # noqa: D417
native_object: IntoLazyFrameT | IntoFrameT | IntoSeriesT | IntoFrame | IntoSeries | T,
*,
strict: bool | None = None,
pass_through: bool | None = None,
eager_only: bool = False,
series_only: bool = False,
allow_series: bool | None = None,
**kwds: Any,
) -> LazyFrame[IntoLazyFrameT] | DataFrame[IntoFrameT] | Series[IntoSeriesT] | T:
"""Convert `native_object` to Narwhals Dataframe, Lazyframe, or Series.

Expand Down Expand Up @@ -351,6 +357,9 @@ def from_native(
pass_through = validate_strict_and_pass_though(
strict, pass_through, pass_through_default=False, emit_deprecation_warning=True
)
if kwds:
msg = f"from_native() got an unexpected keyword argument {next(iter(kwds))!r}"
raise TypeError(msg)
Comment on lines +360 to +362
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

This matches the runtime behavior of python.

import polars as pl
import narwhals as nw

df = pl.DataFrame({"a": ["A", "B", "A"]})
nw_df = nw.from_native(df)
nw.to_native(nw_df, keyword_1="a", keyword_2="b")

Only the first bad argument is ever included:

---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[5], line 39
     37 df = pl.DataFrame({"a": ["A", "B", "A"]})
     38 nw_df = nw.from_native(df)
---> 39 nw.to_native(nw_df, keyword_1="a", keyword_2="b")

TypeError: to_native() got an unexpected keyword argument 'keyword_1'


return _from_native_impl( # type: ignore[no-any-return]
native_object,
Expand Down
3 changes: 2 additions & 1 deletion narwhals/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ def __native_namespace__(self) -> ModuleType: ...
... return df.columns
"""

IntoSeries: TypeAlias = Union["Series[Any]", "NativeSeries"]
IntoSeries: TypeAlias = "NativeSeries"
"""Anything which can be converted to a Narwhals Series.

Use this if your function can accept an object which can be converted to `nw.Series`
Expand Down Expand Up @@ -176,6 +176,7 @@ def __native_namespace__(self) -> ModuleType: ...
"""

LazyFrameT = TypeVar("LazyFrameT", bound="LazyFrame[Any]")
SeriesT = TypeVar("SeriesT", bound="Series[Any]")

IntoSeriesT = TypeVar("IntoSeriesT", bound="IntoSeries")
"""TypeVar bound to object convertible to Narwhals Series.
Expand Down
59 changes: 57 additions & 2 deletions tests/translate/from_native_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from importlib.util import find_spec
from typing import TYPE_CHECKING
from typing import Any
from typing import Callable
from typing import Iterable
from typing import Literal
from typing import cast
Expand All @@ -18,6 +19,7 @@

if TYPE_CHECKING:
from typing_extensions import Self
from typing_extensions import assert_type

from narwhals.utils import Version

Expand Down Expand Up @@ -360,10 +362,63 @@ def test_from_native_lazyframe() -> None:
stable_lazy = nw.from_native(lf_pl)
unstable_lazy = unstable_nw.from_native(lf_pl)
if TYPE_CHECKING:
from typing_extensions import assert_type

assert_type(stable_lazy, nw.LazyFrame[pl.LazyFrame])
assert_type(unstable_lazy, unstable_nw.LazyFrame[pl.LazyFrame])

assert isinstance(stable_lazy, nw.LazyFrame)
assert isinstance(unstable_lazy, unstable_nw.LazyFrame)


def test_series_recursive() -> None:
"""https://github.com/narwhals-dev/narwhals/issues/2239."""
pytest.importorskip("polars")
import polars as pl

pl_series = pl.Series(name="test", values=[1, 2, 3])
nw_series = unstable_nw.from_native(pl_series, series_only=True)
with pytest.raises(AssertionError):
unstable_nw.Series(nw_series, level="full")

nw_series_early_return = unstable_nw.from_native(nw_series, series_only=True)

if TYPE_CHECKING:
assert_type(pl_series, pl.Series)
assert_type(nw_series, unstable_nw.Series[pl.Series])

nw_series_depth_2 = unstable_nw.Series(nw_series, level="full") # type: ignore[var-annotated]
# NOTE: Checking that the type is `Series[Unknown]`
assert_type(nw_series_depth_2, unstable_nw.Series) # type: ignore[type-arg]
assert_type(nw_series_early_return, unstable_nw.Series[pl.Series])


def test_series_recursive_v1() -> None:
"""https://github.com/narwhals-dev/narwhals/issues/2239."""
pytest.importorskip("polars")
import polars as pl

pl_series = pl.Series(name="test", values=[1, 2, 3])
nw_series = nw.from_native(pl_series, series_only=True)
with pytest.raises(AssertionError):
nw.Series(nw_series, level="full")

nw_series_early_return = nw.from_native(nw_series, series_only=True)

if TYPE_CHECKING:
assert_type(pl_series, pl.Series)
assert_type(nw_series, nw.Series[pl.Series])

nw_series_depth_2 = nw.Series(nw_series, level="full")
# NOTE: `Unknown` isn't possible for `v1`, as it has a `TypeVar` default
assert_type(nw_series_depth_2, nw.Series[Any])
assert_type(nw_series_early_return, nw.Series[pl.Series])


@pytest.mark.parametrize("from_native", [unstable_nw.from_native, nw.from_native])
def test_from_native_invalid_keywords(from_native: Callable[..., Any]) -> None:
pattern = r"from_native.+unexpected.+keyword.+bad_1"

with pytest.raises(TypeError, match=pattern):
from_native(data, bad_1="invalid")

with pytest.raises(TypeError, match=pattern):
from_native(data, bad_1="invalid", bad_2="also invalid")
Loading