Repository: narwhals-dev/narwhals
Pull request #2054 (2025-02-20 14:14:17)

feat: str.split (#1932)
  * implemented without inclusive option (https://docs.pola.rs/api/python/dev/reference/expressions/api/polars.Expr.str.split.html)
  * support for _arrow, _dask, _duckdb, spark_like
  * support for pandas_like(pyarrow backed)


<!--
# 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
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues

- Related issue #\<issue number\>
- Closes #\<issue number\>

## Checklist

- [x] Code follows style guide (ruff)
- [x] Tests added
- [x] Documented the changes

## If you have comments or can explain your changes, please do so below


MarcoGorelli: thanks for working on this?

could you make sure your branch is up to date please?
```
git fetch upstream
git merge upstream/main
```
skritsotalakis: > thanks for working on this!
> 
> could you make sure your branch is up to date please?
> 
> ```
> git fetch upstream
> git merge upstream/main
> ```

I just applied (git fetch upstream & git merge upstream/main) but I got "Already up to date". I think I updated beforehands
MarcoGorelli: ah ok thanks, i think you just need to update the code then, there's some failing tests:
```
FAILED tests/expr_and_series/str/split_test.py::test_str_split[duckdb-,-expected1] - AttributeError: 'DuckDBExpr' object has no attribute '_expr_kind'
FAILED tests/expr_and_series/str/split_test.py::test_str_split[dask-_-expected0] - AttributeError: 'DaskExpr' object has no attribute '_returns_scalar'
FAILED tests/expr_and_series/str/split_test.py::test_str_split[duckdb-_-expected0] - AttributeError: 'DuckDBExpr' object has no attribute '_expr_kind'
FAILED tests/expr_and_series/str/split_test.py::test_str_split[dask-,-expected1] - AttributeError: 'DaskExpr' object has no attribute '_returns_scalar'
```

this looks off to a good start anyway! 🙌 
skritsotalakis: Is the shinny issue something I can fix locally?
MarcoGorelli: no it's unrelated, you can ignore it 👍 
MarcoGorelli: thanks! i'll just check how this works with cudf, then we can ship it, this will go in the next release 🚀 
MarcoGorelli: quick note - as cudf are now testing narwhals in their ci, please make sure that the cudf tests pass before merging
MarcoGorelli: Just checked and this fails for cuDF

But in cuDF strings are always arrow-backed, so you can avoid raising an error if implementation is cudf
skritsotalakis: I think i accidentally did it again .. sorry for that. I am going to look at the cudf issue now
skritsotalakis: > Just checked and this fails for cuDF
> 
> But in cuDF strings are always arrow-backed, so you can avoid raising an error if implementation is cudf
 
I think cudf is good now ? https://www.kaggle.com/code/skritsotalakis/cudf-split-test

Patch:
diff --git a/docs/api-reference/expr_str.md b/docs/api-reference/expr_str.md
index 01966a87..bb6abb7e 100644
--- a/docs/api-reference/expr_str.md
+++ b/docs/api-reference/expr_str.md
@@ -11,6 +11,7 @@
         - replace
         - replace_all
         - slice
+        - split
         - starts_with
         - strip_chars
         - tail
diff --git a/docs/api-reference/implementation.md b/docs/api-reference/implementation.md
new file mode 100644
index 00000000..230e9bdc
--- /dev/null
+++ b/docs/api-reference/implementation.md
@@ -0,0 +1,6 @@
+# `narwhals.Implementation`
+
+::: narwhals.Implementation
+    handler: python
+    options:
+      show_source: false
diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md
index a1cd9b7e..3c4a0f42 100644
--- a/docs/api-reference/index.md
+++ b/docs/api-reference/index.md
@@ -18,6 +18,7 @@
 - [narwhals.Series.list](series_list.md)
 - [narwhals.Series.str](series_str.md)
 - [narwhals.dependencies](dependencies.md)
+- [narwhals.Implementation](implementation.md)
 - [narwhals.dtypes](dtypes.md)
 - [narwhals.exceptions](exceptions.md)
 - [narwhals.selectors](selectors.md)
diff --git a/docs/api-reference/series_str.md b/docs/api-reference/series_str.md
index d4ca3602..42810161 100644
--- a/docs/api-reference/series_str.md
+++ b/docs/api-reference/series_str.md
@@ -11,6 +11,7 @@
         - replace
         - replace_all
         - slice
+        - split
         - starts_with
         - strip_chars
         - tail
diff --git a/mkdocs.yml b/mkdocs.yml
index 129c5d97..ad774340 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -59,6 +59,7 @@ nav:
     - api-reference/series_list.md
     - api-reference/series_str.md
     - api-reference/dependencies.md
+    - api-reference/implementation.md
     - api-reference/dtypes.md
     - api-reference/exceptions.md
     - api-reference/selectors.md
diff --git a/narwhals/_arrow/expr_str.py b/narwhals/_arrow/expr_str.py
index 216022ba..11ba7591 100644
--- a/narwhals/_arrow/expr_str.py
+++ b/narwhals/_arrow/expr_str.py
@@ -67,6 +67,11 @@ class ArrowExprStringNamespace:
             self._compliant_expr, "str", "slice", offset=offset, length=length
         )
 
+    def split(self: Self, by: str) -> ArrowExpr:
+        return reuse_series_namespace_implementation(
+            self._compliant_expr, "str", "split", by=by
+        )
+
     def to_datetime(self: Self, format: str | None) -> ArrowExpr:  # noqa: A002
         return reuse_series_namespace_implementation(
             self._compliant_expr, "str", "to_datetime", format=format
diff --git a/narwhals/_arrow/series_str.py b/narwhals/_arrow/series_str.py
index 1de88a66..d44e54b2 100644
--- a/narwhals/_arrow/series_str.py
+++ b/narwhals/_arrow/series_str.py
@@ -72,6 +72,10 @@ class ArrowSeriesStringNamespace:
             )
         )
 
+    def split(self: Self, by: str) -> ArrowSeries:
+        split_series = pc.split_pattern(self._compliant_series._native_series, by)  # type: ignore[call-overload]
+        return self._compliant_series._from_native_series(split_series)
+
     def to_datetime(self: Self, format: str | None) -> ArrowSeries:  # noqa: A002
         native = self._compliant_series._native_series
         format = parse_datetime_format(native) if format is None else format
diff --git a/narwhals/_dask/expr_str.py b/narwhals/_dask/expr_str.py
index f22674b9..d30cca15 100644
--- a/narwhals/_dask/expr_str.py
+++ b/narwhals/_dask/expr_str.py
@@ -81,6 +81,13 @@ class DaskExprStringNamespace:
             length=length,
         )
 
+    def split(self: Self, by: str) -> DaskExpr:
+        return self._compliant_expr._from_call(
+            lambda _input, by: _input.str.split(pat=by),
+            "split",
+            by=by,
+        )
+
     def to_datetime(self: Self, format: str | None) -> DaskExpr:  # noqa: A002
         return self._compliant_expr._from_call(
             lambda _input, format: dd.to_datetime(_input, format=format),  # noqa: A006
diff --git a/narwhals/_duckdb/expr_str.py b/narwhals/_duckdb/expr_str.py
index 63bb829e..b7804662 100644
--- a/narwhals/_duckdb/expr_str.py
+++ b/narwhals/_duckdb/expr_str.py
@@ -54,6 +54,12 @@ class DuckDBExprStringNamespace:
 
         return self._compliant_expr._from_call(func, "slice")
 
+    def split(self: Self, by: str) -> DuckDBExpr:
+        return self._compliant_expr._from_call(
+            lambda _input: FunctionExpression("str_split", _input, lit(by)),
+            "split",
+        )
+
     def len_chars(self: Self) -> DuckDBExpr:
         return self._compliant_expr._from_call(
             lambda _input: FunctionExpression("length", _input), "len_chars"
diff --git a/narwhals/_pandas_like/expr_str.py b/narwhals/_pandas_like/expr_str.py
index da7cf610..8f241515 100644
--- a/narwhals/_pandas_like/expr_str.py
+++ b/narwhals/_pandas_like/expr_str.py
@@ -93,6 +93,11 @@ class PandasLikeExprStringNamespace:
             self._compliant_expr, "str", "slice", offset=offset, length=length
         )
 
+    def split(self: Self, by: str) -> PandasLikeExpr:
+        return reuse_series_namespace_implementation(
+            self._compliant_expr, "str", "split", by=by
+        )
+
     def to_datetime(self: Self, format: str | None) -> PandasLikeExpr:  # noqa: A002
         return reuse_series_namespace_implementation(
             self._compliant_expr,
diff --git a/narwhals/_pandas_like/series_str.py b/narwhals/_pandas_like/series_str.py
index 0cf480ee..5fae5867 100644
--- a/narwhals/_pandas_like/series_str.py
+++ b/narwhals/_pandas_like/series_str.py
@@ -2,7 +2,9 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING
 
+from narwhals._pandas_like.utils import get_dtype_backend
 from narwhals._pandas_like.utils import to_datetime
+from narwhals.utils import Implementation
 
 if TYPE_CHECKING:
     from typing_extensions import Self
@@ -61,6 +63,27 @@ class PandasLikeSeriesStringNamespace:
             self._compliant_series._native_series.str.slice(start=offset, stop=stop),
         )
 
+    def split(self: Self, by: str) -> PandasLikeSeries:
+        if (
+            self._compliant_series._implementation is not Implementation.CUDF
+        ):  # pragma: no cover
+            dtype_backend = get_dtype_backend(
+                self._compliant_series._native_series.dtype,
+                self._compliant_series._implementation,
+            )
+            if dtype_backend != "pyarrow":
+                msg = (
+                    "This operation requires a pyarrow-backed series. "
+                    "Please refer to https://narwhals-dev.github.io/narwhals/api-reference/narwhals/#narwhals.maybe_convert_dtypes "
+                    "and ensure you are using dtype_backend='pyarrow'. "
+                    "Additionally, make sure you have pandas version 1.5+ and pyarrow installed. "
+                )
+                raise TypeError(msg)
+
+        return self._compliant_series._from_native_series(
+            self._compliant_series._native_series.str.split(pat=by),
+        )
+
     def to_datetime(self: Self, format: str | None) -> PandasLikeSeries:  # noqa: A002
         return self._compliant_series._from_native_series(
             to_datetime(self._compliant_series._implementation)(
diff --git a/narwhals/_spark_like/expr_str.py b/narwhals/_spark_like/expr_str.py
index 13962b83..c84eefec 100644
--- a/narwhals/_spark_like/expr_str.py
+++ b/narwhals/_spark_like/expr_str.py
@@ -90,6 +90,12 @@ class SparkLikeExprStringNamespace:
 
         return self._compliant_expr._from_call(func, "slice")
 
+    def split(self: Self, by: str) -> SparkLikeExpr:
+        return self._compliant_expr._from_call(
+            lambda _input: self._compliant_expr._F.split(_input, by),
+            "split",
+        )
+
     def to_uppercase(self: Self) -> SparkLikeExpr:
         return self._compliant_expr._from_call(
             self._compliant_expr._F.upper, "to_uppercase"
diff --git a/narwhals/expr_str.py b/narwhals/expr_str.py
index cb4ddf6b..83b4e20e 100644
--- a/narwhals/expr_str.py
+++ b/narwhals/expr_str.py
@@ -271,6 +271,40 @@ class ExprStringNamespace(Generic[ExprT]):
             self._expr._metadata,
         )
 
+    def split(self: Self, by: str) -> ExprT:
+        r"""Split the string values of an expression by a substring.
+
+        Arguments:
+            by: Substring to split by.
+
+        Returns:
+            A new expression.
+
+        Examples:
+            >>> import polars as pl
+            >>> import narwhals as nw
+            >>> df_native = pl.DataFrame({"s": ["foo bar", "foo_bar"]})
+            >>> df = nw.from_native(df_native)
+            >>> df.with_columns(nw.col("s").str.split("_").alias("s_split"))
+            ┌────────────────────────────┐
+            |     Narwhals DataFrame     |
+            |----------------------------|
+            |shape: (2, 2)               |
+            |┌─────────┬────────────────┐|
+            |│ s       ┆ s_split        │|
+            |│ ---     ┆ ---            │|
+            |│ str     ┆ list[str]      │|
+            |╞═════════╪════════════════╡|
+            |│ foo bar ┆ ["foo bar"]    │|
+            |│ foo_bar ┆ ["foo", "bar"] │|
+            |└─────────┴────────────────┘|
+            └────────────────────────────┘
+        """
+        return self._expr.__class__(
+            lambda plx: self._expr._to_compliant_expr(plx).str.split(by=by),
+            self._expr._metadata,
+        )
+
     def head(self: Self, n: int = 5) -> ExprT:
         r"""Take the first n elements of each string.
 
diff --git a/narwhals/functions.py b/narwhals/functions.py
index 9f64dba2..5e80e05c 100644
--- a/narwhals/functions.py
+++ b/narwhals/functions.py
@@ -9,8 +9,6 @@ from typing import Literal
 from typing import Mapping
 from typing import Protocol
 from typing import Sequence
-from typing import TypeVar
-from typing import Union
 from typing import overload
 
 from narwhals._expression_parsing import ExprKind
@@ -21,8 +19,6 @@ from narwhals._expression_parsing import combine_metadata
 from narwhals._expression_parsing import extract_compliant
 from narwhals._expression_parsing import infer_kind
 from narwhals._expression_parsing import is_scalar_like
-from narwhals.dataframe import DataFrame
-from narwhals.dataframe import LazyFrame
 from narwhals.dependencies import is_numpy_array
 from narwhals.dependencies import is_numpy_array_2d
 from narwhals.expr import Expr
@@ -39,12 +35,6 @@ from narwhals.utils import parse_version
 from narwhals.utils import validate_laziness
 from narwhals.utils import validate_native_namespace_and_backend
 
-# Missing type parameters for generic type "DataFrame"
-# However, trying to provide one results in mypy still complaining...
-# The rest of the annotations seem to work fine with this anyway
-FrameT = TypeVar("FrameT", bound=Union[DataFrame, LazyFrame])  # type: ignore[type-arg]
-
-
 if TYPE_CHECKING:
     from types import ModuleType
 
@@ -52,6 +42,8 @@ if TYPE_CHECKING:
     import pyarrow as pa
     from typing_extensions import Self
 
+    from narwhals.dataframe import DataFrame
+    from narwhals.dataframe import LazyFrame
     from narwhals.dtypes import DType
     from narwhals.series import Series
     from narwhals.typing import CompliantExpr
diff --git a/narwhals/group_by.py b/narwhals/group_by.py
index e299d17d..ed86aa46 100644
--- a/narwhals/group_by.py
+++ b/narwhals/group_by.py
@@ -6,29 +6,25 @@ from typing import Generic
 from typing import Iterable
 from typing import Iterator
 from typing import TypeVar
-from typing import cast
 
 from narwhals._expression_parsing import all_exprs_are_scalar_like
-from narwhals.dataframe import DataFrame
-from narwhals.dataframe import LazyFrame
 from narwhals.exceptions import InvalidOperationError
+from narwhals.typing import DataFrameT
 from narwhals.utils import flatten
 from narwhals.utils import tupleify
 
 if TYPE_CHECKING:
     from typing_extensions import Self
 
-    from narwhals.dataframe import DataFrame
     from narwhals.dataframe import LazyFrame
     from narwhals.expr import Expr
 
-DataFrameT = TypeVar("DataFrameT")
-LazyFrameT = TypeVar("LazyFrameT")
+LazyFrameT = TypeVar("LazyFrameT", bound="LazyFrame[Any]")
 
 
 class GroupBy(Generic[DataFrameT]):
     def __init__(self: Self, df: DataFrameT, *keys: str, drop_null_keys: bool) -> None:
-        self._df = cast("DataFrame[Any]", df)
+        self._df: DataFrameT = df
         self._keys = keys
         self._grouped = self._df._compliant_frame.group_by(
             *self._keys, drop_null_keys=drop_null_keys
@@ -131,12 +127,10 @@ class GroupBy(Generic[DataFrameT]):
                 for key, value in named_aggs.items()
             ),
         )
-        return self._df._from_compliant_dataframe(  # type: ignore[return-value]
-            self._grouped.agg(*compliant_aggs),
-        )
+        return self._df._from_compliant_dataframe(self._grouped.agg(*compliant_aggs))
 
     def __iter__(self: Self) -> Iterator[tuple[Any, DataFrameT]]:
-        yield from (  # type: ignore[misc]
+        yield from (
             (tupleify(key), self._df._from_compliant_dataframe(df))
             for (key, df) in self._grouped.__iter__()
         )
@@ -144,7 +138,7 @@ class GroupBy(Generic[DataFrameT]):
 
 class LazyGroupBy(Generic[LazyFrameT]):
     def __init__(self: Self, df: LazyFrameT, *keys: str, drop_null_keys: bool) -> None:
-        self._df = cast("LazyFrame[Any]", df)
+        self._df: LazyFrameT = df
         self._keys = keys
         self._grouped = self._df._compliant_frame.group_by(
             *self._keys, drop_null_keys=drop_null_keys
@@ -231,6 +225,4 @@ class LazyGroupBy(Generic[LazyFrameT]):
                 for key, value in named_aggs.items()
             ),
         )
-        return self._df._from_compliant_dataframe(  # type: ignore[return-value]
-            self._grouped.agg(*compliant_aggs),
-        )
+        return self._df._from_compliant_dataframe(self._grouped.agg(*compliant_aggs))
diff --git a/narwhals/series_str.py b/narwhals/series_str.py
index 873feda1..98e42807 100644
--- a/narwhals/series_str.py
+++ b/narwhals/series_str.py
@@ -236,6 +236,32 @@ class SeriesStringNamespace(Generic[SeriesT]):
             )
         )
 
+    def split(self: Self, by: str) -> SeriesT:
+        r"""Split the string values of a Series by a substring.
+
+        Arguments:
+            by: Substring to split by.
+
+        Returns:
+            A new Series containing lists of strings.
+
+        Examples:
+            >>> import polars as pl
+            >>> import narwhals as nw
+            >>> s_native = pl.Series(["foo bar", "foo_bar"])
+            >>> s = nw.from_native(s_native, series_only=True)
+            >>> s.str.split("_").to_native()  # doctest: +NORMALIZE_WHITESPACE
+            shape: (2,)
+            Series: '' [list[str]]
+            [
+                    ["foo bar"]
+                    ["foo", "bar"]
+            ]
+        """
+        return self._narwhals_series._from_compliant_series(
+            self._narwhals_series._compliant_series.str.split(by=by)
+        )
+
     def head(self: Self, n: int = 5) -> SeriesT:
         r"""Take the first n elements of each string.
 
diff --git a/narwhals/stable/v1/__init__.py b/narwhals/stable/v1/__init__.py
index c43143a0..cececde6 100644
--- a/narwhals/stable/v1/__init__.py
+++ b/narwhals/stable/v1/__init__.py
@@ -7,7 +7,6 @@ from typing import Callable
 from typing import Iterable
 from typing import Literal
 from typing import Sequence
-from typing import TypeVar
 from typing import overload
 from warnings import warn
 
@@ -86,6 +85,7 @@ if TYPE_CHECKING:
     from typing import Mapping
 
     from typing_extensions import Self
+    from typing_extensions import TypeVar
 
     from narwhals.dtypes import DType
     from narwhals.functions import ArrowStreamExportable
@@ -95,7 +95,13 @@ if TYPE_CHECKING:
     from narwhals.typing import _1DArray
     from narwhals.typing import _2DArray
 
-T = TypeVar("T")
+    IntoSeriesT = TypeVar("IntoSeriesT", bound="IntoSeries", default=Any)
+    T = TypeVar("T", default=Any)
+else:
+    from typing import TypeVar
+
+    IntoSeriesT = TypeVar("IntoSeriesT", bound="IntoSeries")
+    T = TypeVar("T")
 
 
 class DataFrame(NwDataFrame[IntoDataFrameT]):
@@ -127,7 +133,7 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
     # annotations are correct.
 
     @property
-    def _series(self: Self) -> type[Series]:
+    def _series(self: Self) -> type[Series[Any]]:
         return Series
 
     @property
@@ -138,7 +144,7 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
     def __getitem__(  # type: ignore[overload-overlap]
         self: Self,
         item: str | tuple[slice | Sequence[int] | _1DArray, int | str],
-    ) -> Series: ...
+    ) -> Series[Any]: ...
     @overload
     def __getitem__(
         self: Self,
@@ -192,16 +198,18 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
     # Not sure what mypy is complaining about, probably some fancy
     # thing that I need to understand category theory for
     @overload  # type: ignore[override]
-    def to_dict(self: Self, *, as_series: Literal[True] = ...) -> dict[str, Series]: ...
+    def to_dict(
+        self: Self, *, as_series: Literal[True] = ...
+    ) -> dict[str, Series[Any]]: ...
     @overload
     def to_dict(self: Self, *, as_series: Literal[False]) -> dict[str, list[Any]]: ...
     @overload
     def to_dict(
         self: Self, *, as_series: bool
-    ) -> dict[str, Series] | dict[str, list[Any]]: ...
+    ) -> dict[str, Series[Any]] | dict[str, list[Any]]: ...
     def to_dict(
         self: Self, *, as_series: bool = True
-    ) -> dict[str, Series] | dict[str, list[Any]]:
+    ) -> dict[str, Series[Any]] | dict[str, list[Any]]:
         """Convert DataFrame to a dictionary mapping column name to values.
 
         Arguments:
@@ -213,7 +221,7 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
         """
         return super().to_dict(as_series=as_series)  # type: ignore[return-value]
 
-    def is_duplicated(self: Self) -> Series:
+    def is_duplicated(self: Self) -> Series[Any]:
         r"""Get a mask of all duplicated rows in this DataFrame.
 
         Returns:
@@ -221,7 +229,7 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
         """
         return super().is_duplicated()  # type: ignore[return-value]
 
-    def is_unique(self: Self) -> Series:
+    def is_unique(self: Self) -> Series[Any]:
         r"""Get a mask of all unique rows in this DataFrame.
 
         Returns:
@@ -341,7 +349,7 @@ class LazyFrame(NwLazyFrame[IntoFrameT]):
         return super().tail(n)
 
 
-class Series(NwSeries[Any]):
+class Series(NwSeries[IntoSeriesT]):
     """Narwhals Series, backed by a native series.
 
     !!! warning
@@ -1102,7 +1110,7 @@ def _stableify(obj: NwDataFrame[IntoFrameT]) -> DataFrame[IntoFrameT]: ...
 @overload
 def _stableify(obj: NwLazyFrame[IntoFrameT]) -> LazyFrame[IntoFrameT]: ...
 @overload
-def _stableify(obj: NwSeries[Any]) -> Series: ...
+def _stableify(obj: NwSeries[IntoSeriesT]) -> Series[IntoSeriesT]: ...
 @overload
 def _stableify(obj: NwExpr) -> Expr: ...
 @overload
@@ -1110,8 +1118,12 @@ def _stableify(obj: Any) -> Any: ...
 
 
 def _stableify(
-    obj: NwDataFrame[IntoFrameT] | NwLazyFrame[IntoFrameT] | NwSeries[Any] | NwExpr | Any,
-) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT] | Series | Expr | Any:
+    obj: NwDataFrame[IntoFrameT]
+    | NwLazyFrame[IntoFrameT]
+    | NwSeries[IntoSeriesT]
+    | NwExpr
+    | Any,
+) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT] | Series[IntoSeriesT] | Expr | Any:
     if isinstance(obj, NwDataFrame):
         return DataFrame(
             obj._compliant_frame._change_version(Version.V1),
@@ -1134,26 +1146,26 @@ def _stableify(
 
 @overload
 def from_native(
-    native_object: IntoDataFrameT | IntoSeries,
+    native_object: IntoDataFrameT | IntoSeriesT,
     *,
     strict: Literal[False],
     eager_only: Literal[False] = ...,
     eager_or_interchange_only: Literal[True],
     series_only: Literal[False] = ...,
     allow_series: Literal[True],
-) -> DataFrame[IntoDataFrameT] | Series: ...
+) -> DataFrame[IntoDataFrameT] | Series[IntoSeriesT]: ...
 
 
 @overload
 def from_native(
-    native_object: IntoDataFrameT | IntoSeries,
+    native_object: IntoDataFrameT | IntoSeriesT,
     *,
     strict: Literal[False],
     eager_only: Literal[True],
     eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[False] = ...,
     allow_series: Literal[True],
-) -> DataFrame[IntoDataFrameT] | Series: ...
+) -> DataFrame[IntoDataFrameT] | Series[IntoSeriesT]: ...
 
 
 @overload
@@ -1206,26 +1218,26 @@ def from_native(
 
 @overload
 def from_native(
-    native_object: IntoFrameT | IntoSeries,
+    native_object: IntoFrameT | IntoSeriesT,
     *,
     strict: Literal[False],
     eager_only: Literal[False] = ...,
     eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[False] = ...,
     allow_series: Literal[True],
-) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT] | Series: ...
+) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT] | Series[IntoSeriesT]: ...
 
 
 @overload
 def from_native(
-    native_object: IntoSeries,
+    native_object: IntoSeriesT,
     *,
     strict: Literal[False],
     eager_only: Literal[False] = ...,
     eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[True],
     allow_series: None = ...,
-) -> Series: ...
+) -> Series[IntoSeriesT]: ...
 
 
 @overload
@@ -1285,19 +1297,19 @@ def from_native(
     eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[False] = ...,
     allow_series: Literal[True],
-) -> DataFrame[Any] | LazyFrame[Any] | Series: ...
+) -> DataFrame[Any] | LazyFrame[Any] | Series[Any]: ...
 
 
 @overload
 def from_native(
-    native_object: IntoSeries | Any,  # remain `Any` for downstream compatibility
+    native_object: IntoSeriesT,
     *,
     strict: Literal[True] = ...,
     eager_only: Literal[False] = ...,
     eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[True],
     allow_series: None = ...,
-) -> Series: ...
+) -> Series[IntoSeriesT]: ...
 
 
 @overload
@@ -1326,14 +1338,14 @@ def from_native(
 
 @overload
 def from_native(
-    native_object: IntoDataFrameT | IntoSeries,
+    native_object: IntoDataFrameT | IntoSeriesT,
     *,
     pass_through: Literal[True],
     eager_only: Literal[True],
     eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[False] = ...,
     allow_series: Literal[True],
-) -> DataFrame[IntoDataFrameT] | Series: ...
+) -> DataFrame[IntoDataFrameT] | Series[IntoSeriesT]: ...
 
 
 @overload
@@ -1386,26 +1398,26 @@ def from_native(
 
 @overload
 def from_native(
-    native_object: IntoFrameT | IntoSeries,
+    native_object: IntoFrameT | IntoSeriesT,
     *,
     pass_through: Literal[True],
     eager_only: Literal[False] = ...,
     eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[False] = ...,
     allow_series: Literal[True],
-) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT] | Series: ...
+) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT] | Series[IntoSeriesT]: ...
 
 
 @overload
 def from_native(
-    native_object: IntoSeries,
+    native_object: IntoSeriesT,
     *,
     pass_through: Literal[True],
     eager_only: Literal[False] = ...,
     eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[True],
     allow_series: None = ...,
-) -> Series: ...
+) -> Series[IntoSeriesT]: ...
 
 
 @overload
@@ -1465,19 +1477,19 @@ def from_native(
     eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[False] = ...,
     allow_series: Literal[True],
-) -> DataFrame[Any] | LazyFrame[Any] | Series: ...
+) -> DataFrame[Any] | LazyFrame[Any] | Series[Any]: ...
 
 
 @overload
 def from_native(
-    native_object: IntoSeries,
+    native_object: IntoSeriesT,
     *,
     pass_through: Literal[False] = ...,
     eager_only: Literal[False] = ...,
     eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[True],
     allow_series: None = ...,
-) -> Series: ...
+) -> Series[IntoSeriesT]: ...
 
 
 @overload
@@ -1506,7 +1518,7 @@ def from_native(
 
 
 def from_native(
-    native_object: IntoFrameT | IntoFrame | IntoSeries | T,
+    native_object: IntoFrameT | IntoFrame | IntoSeriesT | IntoSeries | T,
     *,
     strict: bool | None = None,
     pass_through: bool | None = None,
@@ -1514,7 +1526,7 @@ def from_native(
     eager_or_interchange_only: bool = False,
     series_only: bool = False,
     allow_series: bool | None = None,
-) -> LazyFrame[IntoFrameT] | DataFrame[IntoFrameT] | Series | T:
+) -> LazyFrame[IntoFrameT] | DataFrame[IntoFrameT] | Series[IntoSeriesT] | T:
     """Convert `native_object` to Narwhals Dataframe, Lazyframe, or Series.
 
     Arguments:
@@ -1595,7 +1607,9 @@ def to_native(
     narwhals_object: LazyFrame[IntoFrameT], *, strict: Literal[True] = ...
 ) -> IntoFrameT: ...
 @overload
-def to_native(narwhals_object: Series, *, strict: Literal[True] = ...) -> Any: ...
+def to_native(
+    narwhals_object: Series[IntoSeriesT], *, strict: Literal[True] = ...
+) -> IntoSeriesT: ...
 @overload
 def to_native(narwhals_object: Any, *, strict: bool) -> Any: ...
 @overload
@@ -1607,17 +1621,21 @@ def to_native(
     narwhals_object: LazyFrame[IntoFrameT], *, pass_through: Literal[False] = ...
 ) -> IntoFrameT: ...
 @overload
-def to_native(narwhals_object: Series, *, pass_through: Literal[False] = ...) -> Any: ...
+def to_native(
+    narwhals_object: Series[IntoSeriesT], *, pass_through: Literal[False] = ...
+) -> IntoSeriesT: ...
 @overload
 def to_native(narwhals_object: Any, *, pass_through: bool) -> Any: ...
 
 
 def to_native(
-    narwhals_object: DataFrame[IntoDataFrameT] | LazyFrame[IntoFrameT] | Series,
+    narwhals_object: DataFrame[IntoDataFrameT]
+    | LazyFrame[IntoFrameT]
+    | Series[IntoSeriesT],
     *,
     strict: bool | None = None,
     pass_through: bool | None = None,
-) -> IntoFrameT | Any:
+) -> IntoFrameT | IntoSeriesT | Any:
     """Convert Narwhals object to native one.
 
     Arguments:
@@ -2124,7 +2142,7 @@ def new_series(
     dtype: DType | type[DType] | None = None,
     *,
     native_namespace: ModuleType,
-) -> Series:
+) -> Series[Any]:
     """Instantiate Narwhals Series from iterable (e.g. list or array).
 
     Arguments:
diff --git a/narwhals/stable/v1/typing.py b/narwhals/stable/v1/typing.py
index fa7cd67f..94df953f 100644
--- a/narwhals/stable/v1/typing.py
+++ b/narwhals/stable/v1/typing.py
@@ -1,215 +1,215 @@
-from __future__ import annotations
-
-from typing import TYPE_CHECKING
-from typing import Any
-from typing import Protocol
-from typing import TypeVar
-from typing import Union
-
-if TYPE_CHECKING:
-    import sys
-
-    if sys.version_info >= (3, 10):
-        from typing import TypeAlias
-    else:
-        from typing_extensions import TypeAlias
-
-    from narwhals.stable.v1 import DataFrame
-    from narwhals.stable.v1 import Expr
-    from narwhals.stable.v1 import LazyFrame
-    from narwhals.stable.v1 import Series
-    from narwhals.stable.v1 import dtypes
-
-    # All dataframes supported by Narwhals have a
-    # `columns` property. Their similarities don't extend
-    # _that_ much further unfortunately...
-    class NativeFrame(Protocol):
-        @property
-        def columns(self) -> Any: ...
-
-        def join(self, *args: Any, **kwargs: Any) -> Any: ...
-
-    class NativeSeries(Protocol):
-        def __len__(self) -> int: ...
-
-    class DataFrameLike(Protocol):
-        def __dataframe__(self, *args: Any, **kwargs: Any) -> Any: ...
-
-
-IntoExpr: TypeAlias = Union["Expr", str, "Series"]
-"""Anything which can be converted to an expression.
-
-Use this to mean "either a Narwhals expression, or something
-which can be converted into one". For example, `exprs` in `DataFrame.select` is
-typed to accept `IntoExpr`, as it can either accept a `nw.Expr`
-(e.g. `df.select(nw.col('a'))`) or a string which will be interpreted as a
-`nw.Expr`, e.g. `df.select('a')`.
-"""
-
-IntoDataFrame: TypeAlias = Union["NativeFrame", "DataFrame[Any]", "DataFrameLike"]
-"""Anything which can be converted to a Narwhals DataFrame.
-
-Use this if your function accepts a narwhalifiable object but doesn't care about its backend.
-
-Examples:
-    >>> import narwhals as nw
-    >>> from narwhals.typing import IntoDataFrame
-    >>> def agnostic_shape(df_native: IntoDataFrame) -> tuple[int, int]:
-    ...     df = nw.from_native(df_native, eager_only=True)
-    ...     return df.shape
-"""
-
-IntoFrame: TypeAlias = Union[
-    "NativeFrame", "DataFrame[Any]", "LazyFrame[Any]", "DataFrameLike"
-]
-"""Anything which can be converted to a Narwhals DataFrame or LazyFrame.
-
-Use this if your function can accept an object which can be converted to either
-`nw.DataFrame` or `nw.LazyFrame` and it doesn't care about its backend.
-
-Examples:
-    >>> import narwhals as nw
-    >>> from narwhals.typing import IntoFrame
-    >>> def agnostic_columns(df_native: IntoFrame) -> list[str]:
-    ...     df = nw.from_native(df_native)
-    ...     return df.collect_schema().names()
-"""
-
-Frame: TypeAlias = Union["DataFrame[Any]", "LazyFrame[Any]"]
-"""Narwhals DataFrame or Narwhals LazyFrame.
-
-Use this if your function can work with either and your function doesn't care
-about its backend.
-
-Examples:
-    >>> import narwhals as nw
-    >>> from narwhals.typing import Frame
-    >>> @nw.narwhalify
-    ... def agnostic_columns(df: Frame) -> list[str]:
-    ...     return df.columns
-"""
-
-IntoSeries: TypeAlias = Union["Series", "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`
-and it doesn't care about its backend.
-
-Examples:
-    >>> from typing import Any
-    >>> import narwhals as nw
-    >>> from narwhals.typing import IntoSeries
-    >>> def agnostic_to_list(s_native: IntoSeries) -> list[Any]:
-    ...     s = nw.from_native(s_native)
-    ...     return s.to_list()
-"""
-
-IntoFrameT = TypeVar("IntoFrameT", bound="IntoFrame")
-"""TypeVar bound to object convertible to Narwhals DataFrame or Narwhals LazyFrame.
-
-Use this if your function accepts an object which is convertible to `nw.DataFrame`
-or `nw.LazyFrame` and returns an object of the same type.
-
-Examples:
-    >>> import narwhals as nw
-    >>> from narwhals.typing import IntoFrameT
-    >>> def agnostic_func(df_native: IntoFrameT) -> IntoFrameT:
-    ...     df = nw.from_native(df_native)
-    ...     return df.with_columns(c=nw.col("a") + 1).to_native()
-"""
-
-IntoDataFrameT = TypeVar("IntoDataFrameT", bound="IntoDataFrame")
-"""TypeVar bound to object convertible to Narwhals DataFrame.
-
-Use this if your function accepts an object which can be converted to `nw.DataFrame`
-and returns an object of the same class.
-
-Examples:
-    >>> import narwhals as nw
-    >>> from narwhals.typing import IntoDataFrameT
-    >>> def agnostic_func(df_native: IntoDataFrameT) -> IntoDataFrameT:
-    ...     df = nw.from_native(df_native, eager_only=True)
-    ...     return df.with_columns(c=df["a"] + 1).to_native()
-"""
-
-FrameT = TypeVar("FrameT", bound="Frame")
-"""TypeVar bound to Narwhals DataFrame or Narwhals LazyFrame.
-
-Use this if your function accepts either `nw.DataFrame` or `nw.LazyFrame` and returns
-an object of the same kind.
-
-Examples:
-    >>> import narwhals as nw
-    >>> from narwhals.typing import FrameT
-    >>> @nw.narwhalify
-    ... def agnostic_func(df: FrameT) -> FrameT:
-    ...     return df.with_columns(c=nw.col("a") + 1)
-"""
-
-DataFrameT = TypeVar("DataFrameT", bound="DataFrame[Any]")
-"""TypeVar bound to Narwhals DataFrame.
-
-Use this if your function can accept a Narwhals DataFrame and returns a Narwhals
-DataFrame backed by the same backend.
-
-Examples:
-    >>> import narwhals as nw
-    >>> from narwhals.typing import DataFrameT
-    >>> @nw.narwhalify
-    >>> def func(df: DataFrameT) -> DataFrameT:
-    ...     return df.with_columns(c=df["a"] + 1)
-"""
-
-IntoSeriesT = TypeVar("IntoSeriesT", bound="IntoSeries")
-"""TypeVar bound to object convertible to Narwhals Series.
-
-Use this if your function accepts an object  which can be converted to `nw.Series`
-and returns an object of the same class.
-
-Examples:
-    >>> import narwhals as nw
-    >>> from narwhals.typing import IntoSeriesT
-    >>> def agnostic_abs(s_native: IntoSeriesT) -> IntoSeriesT:
-    ...     s = nw.from_native(s_native, series_only=True)
-    ...     return s.abs().to_native()
-"""
-
-
-class DTypes:
-    Int64: type[dtypes.Int64]
-    Int32: type[dtypes.Int32]
-    Int16: type[dtypes.Int16]
-    Int8: type[dtypes.Int8]
-    UInt64: type[dtypes.UInt64]
-    UInt32: type[dtypes.UInt32]
-    UInt16: type[dtypes.UInt16]
-    UInt8: type[dtypes.UInt8]
-    Float64: type[dtypes.Float64]
-    Float32: type[dtypes.Float32]
-    String: type[dtypes.String]
-    Boolean: type[dtypes.Boolean]
-    Object: type[dtypes.Object]
-    Categorical: type[dtypes.Categorical]
-    Enum: type[dtypes.Enum]
-    Datetime: type[dtypes.Datetime]
-    Duration: type[dtypes.Duration]
-    Date: type[dtypes.Date]
-    Field: type[dtypes.Field]
-    Struct: type[dtypes.Struct]
-    List: type[dtypes.List]
-    Array: type[dtypes.Array]
-    Unknown: type[dtypes.Unknown]
-
-
-__all__ = [
-    "DataFrameT",
-    "Frame",
-    "FrameT",
-    "IntoDataFrame",
-    "IntoDataFrameT",
-    "IntoExpr",
-    "IntoFrame",
-    "IntoFrameT",
-    "IntoSeries",
-    "IntoSeriesT",
-]
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from typing import Any
+from typing import Protocol
+from typing import TypeVar
+from typing import Union
+
+if TYPE_CHECKING:
+    import sys
+
+    if sys.version_info >= (3, 10):
+        from typing import TypeAlias
+    else:
+        from typing_extensions import TypeAlias
+
+    from narwhals.stable.v1 import DataFrame
+    from narwhals.stable.v1 import Expr
+    from narwhals.stable.v1 import LazyFrame
+    from narwhals.stable.v1 import Series
+    from narwhals.stable.v1 import dtypes
+
+    # All dataframes supported by Narwhals have a
+    # `columns` property. Their similarities don't extend
+    # _that_ much further unfortunately...
+    class NativeFrame(Protocol):
+        @property
+        def columns(self) -> Any: ...
+
+        def join(self, *args: Any, **kwargs: Any) -> Any: ...
+
+    class NativeSeries(Protocol):
+        def __len__(self) -> int: ...
+
+    class DataFrameLike(Protocol):
+        def __dataframe__(self, *args: Any, **kwargs: Any) -> Any: ...
+
+
+IntoExpr: TypeAlias = Union["Expr", str, "Series[Any]"]
+"""Anything which can be converted to an expression.
+
+Use this to mean "either a Narwhals expression, or something
+which can be converted into one". For example, `exprs` in `DataFrame.select` is
+typed to accept `IntoExpr`, as it can either accept a `nw.Expr`
+(e.g. `df.select(nw.col('a'))`) or a string which will be interpreted as a
+`nw.Expr`, e.g. `df.select('a')`.
+"""
+
+IntoDataFrame: TypeAlias = Union["NativeFrame", "DataFrame[Any]", "DataFrameLike"]
+"""Anything which can be converted to a Narwhals DataFrame.
+
+Use this if your function accepts a narwhalifiable object but doesn't care about its backend.
+
+Examples:
+    >>> import narwhals as nw
+    >>> from narwhals.typing import IntoDataFrame
+    >>> def agnostic_shape(df_native: IntoDataFrame) -> tuple[int, int]:
+    ...     df = nw.from_native(df_native, eager_only=True)
+    ...     return df.shape
+"""
+
+IntoFrame: TypeAlias = Union[
+    "NativeFrame", "DataFrame[Any]", "LazyFrame[Any]", "DataFrameLike"
+]
+"""Anything which can be converted to a Narwhals DataFrame or LazyFrame.
+
+Use this if your function can accept an object which can be converted to either
+`nw.DataFrame` or `nw.LazyFrame` and it doesn't care about its backend.
+
+Examples:
+    >>> import narwhals as nw
+    >>> from narwhals.typing import IntoFrame
+    >>> def agnostic_columns(df_native: IntoFrame) -> list[str]:
+    ...     df = nw.from_native(df_native)
+    ...     return df.collect_schema().names()
+"""
+
+Frame: TypeAlias = Union["DataFrame[Any]", "LazyFrame[Any]"]
+"""Narwhals DataFrame or Narwhals LazyFrame.
+
+Use this if your function can work with either and your function doesn't care
+about its backend.
+
+Examples:
+    >>> import narwhals as nw
+    >>> from narwhals.typing import Frame
+    >>> @nw.narwhalify
+    ... def agnostic_columns(df: Frame) -> list[str]:
+    ...     return df.columns
+"""
+
+IntoSeries: TypeAlias = Union["Series[Any]", "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`
+and it doesn't care about its backend.
+
+Examples:
+    >>> from typing import Any
+    >>> import narwhals as nw
+    >>> from narwhals.typing import IntoSeries
+    >>> def agnostic_to_list(s_native: IntoSeries) -> list[Any]:
+    ...     s = nw.from_native(s_native)
+    ...     return s.to_list()
+"""
+
+IntoFrameT = TypeVar("IntoFrameT", bound="IntoFrame")
+"""TypeVar bound to object convertible to Narwhals DataFrame or Narwhals LazyFrame.
+
+Use this if your function accepts an object which is convertible to `nw.DataFrame`
+or `nw.LazyFrame` and returns an object of the same type.
+
+Examples:
+    >>> import narwhals as nw
+    >>> from narwhals.typing import IntoFrameT
+    >>> def agnostic_func(df_native: IntoFrameT) -> IntoFrameT:
+    ...     df = nw.from_native(df_native)
+    ...     return df.with_columns(c=nw.col("a") + 1).to_native()
+"""
+
+IntoDataFrameT = TypeVar("IntoDataFrameT", bound="IntoDataFrame")
+"""TypeVar bound to object convertible to Narwhals DataFrame.
+
+Use this if your function accepts an object which can be converted to `nw.DataFrame`
+and returns an object of the same class.
+
+Examples:
+    >>> import narwhals as nw
+    >>> from narwhals.typing import IntoDataFrameT
+    >>> def agnostic_func(df_native: IntoDataFrameT) -> IntoDataFrameT:
+    ...     df = nw.from_native(df_native, eager_only=True)
+    ...     return df.with_columns(c=df["a"] + 1).to_native()
+"""
+
+FrameT = TypeVar("FrameT", bound="Frame")
+"""TypeVar bound to Narwhals DataFrame or Narwhals LazyFrame.
+
+Use this if your function accepts either `nw.DataFrame` or `nw.LazyFrame` and returns
+an object of the same kind.
+
+Examples:
+    >>> import narwhals as nw
+    >>> from narwhals.typing import FrameT
+    >>> @nw.narwhalify
+    ... def agnostic_func(df: FrameT) -> FrameT:
+    ...     return df.with_columns(c=nw.col("a") + 1)
+"""
+
+DataFrameT = TypeVar("DataFrameT", bound="DataFrame[Any]")
+"""TypeVar bound to Narwhals DataFrame.
+
+Use this if your function can accept a Narwhals DataFrame and returns a Narwhals
+DataFrame backed by the same backend.
+
+Examples:
+    >>> import narwhals as nw
+    >>> from narwhals.typing import DataFrameT
+    >>> @nw.narwhalify
+    >>> def func(df: DataFrameT) -> DataFrameT:
+    ...     return df.with_columns(c=df["a"] + 1)
+"""
+
+IntoSeriesT = TypeVar("IntoSeriesT", bound="IntoSeries")
+"""TypeVar bound to object convertible to Narwhals Series.
+
+Use this if your function accepts an object  which can be converted to `nw.Series`
+and returns an object of the same class.
+
+Examples:
+    >>> import narwhals as nw
+    >>> from narwhals.typing import IntoSeriesT
+    >>> def agnostic_abs(s_native: IntoSeriesT) -> IntoSeriesT:
+    ...     s = nw.from_native(s_native, series_only=True)
+    ...     return s.abs().to_native()
+"""
+
+
+class DTypes:
+    Int64: type[dtypes.Int64]
+    Int32: type[dtypes.Int32]
+    Int16: type[dtypes.Int16]
+    Int8: type[dtypes.Int8]
+    UInt64: type[dtypes.UInt64]
+    UInt32: type[dtypes.UInt32]
+    UInt16: type[dtypes.UInt16]
+    UInt8: type[dtypes.UInt8]
+    Float64: type[dtypes.Float64]
+    Float32: type[dtypes.Float32]
+    String: type[dtypes.String]
+    Boolean: type[dtypes.Boolean]
+    Object: type[dtypes.Object]
+    Categorical: type[dtypes.Categorical]
+    Enum: type[dtypes.Enum]
+    Datetime: type[dtypes.Datetime]
+    Duration: type[dtypes.Duration]
+    Date: type[dtypes.Date]
+    Field: type[dtypes.Field]
+    Struct: type[dtypes.Struct]
+    List: type[dtypes.List]
+    Array: type[dtypes.Array]
+    Unknown: type[dtypes.Unknown]
+
+
+__all__ = [
+    "DataFrameT",
+    "Frame",
+    "FrameT",
+    "IntoDataFrame",
+    "IntoDataFrameT",
+    "IntoExpr",
+    "IntoFrame",
+    "IntoFrameT",
+    "IntoSeries",
+    "IntoSeriesT",
+]


Test patch:
diff --git a/tests/expr_and_series/dt/timestamp_test.py b/tests/expr_and_series/dt/timestamp_test.py
index 448091ca..ed22a16d 100644
--- a/tests/expr_and_series/dt/timestamp_test.py
+++ b/tests/expr_and_series/dt/timestamp_test.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 from datetime import datetime
+from typing import TYPE_CHECKING
 from typing import Literal
 
 import hypothesis.strategies as st
@@ -18,6 +19,9 @@ from tests.utils import ConstructorEager
 from tests.utils import assert_equal_data
 from tests.utils import is_windows
 
+if TYPE_CHECKING:
+    from narwhals.typing import IntoSeriesT
+
 data = {
     "a": [
         datetime(2021, 3, 1, 12, 34, 56, 49000),
@@ -221,7 +225,7 @@ def test_timestamp_hypothesis(
     import polars as pl
 
     @nw.narwhalify
-    def func(s: nw.Series) -> nw.Series:
+    def func(s: nw.Series[IntoSeriesT]) -> nw.Series[IntoSeriesT]:
         return s.dt.timestamp(time_unit)
 
     result_pl = func(pl.Series([inputs], dtype=pl.Datetime(starting_time_unit)))
diff --git a/tests/expr_and_series/str/split_test.py b/tests/expr_and_series/str/split_test.py
new file mode 100644
index 00000000..13e2c371
--- /dev/null
+++ b/tests/expr_and_series/str/split_test.py
@@ -0,0 +1,71 @@
+from __future__ import annotations
+
+import re
+from typing import Any
+
+import pytest
+
+import narwhals.stable.v1 as nw
+from tests.utils import Constructor
+from tests.utils import ConstructorEager
+from tests.utils import assert_equal_data
+
+data = {"s": ["foo bar", "foo_bar", "foo_bar_baz", "foo,bar"]}
+
+
+@pytest.mark.parametrize(
+    ("by", "expected"),
+    [
+        ("_", {"s": [["foo bar"], ["foo", "bar"], ["foo", "bar", "baz"], ["foo,bar"]]}),
+        (
+            ",",
+            {"s": [["foo bar"], ["foo_bar"], ["foo_bar_baz"], ["foo", "bar"]]},
+        ),
+    ],
+)
+def test_str_split(
+    constructor: Constructor,
+    by: str,
+    expected: Any,
+) -> None:
+    if "cudf" not in str(constructor) and (
+        constructor.__name__.startswith("pandas")
+        and "pyarrow" not in constructor.__name__
+    ):
+        df = nw.from_native(constructor(data))
+        msg = re.escape("This operation requires a pyarrow-backed series. ")
+        with pytest.raises(TypeError, match=msg):
+            df.select(nw.col("s").str.split(by=by))
+        return
+    df = nw.from_native(constructor(data))
+    result_frame = df.select(nw.col("s").str.split(by=by))
+    assert_equal_data(result_frame, expected)
+
+
+@pytest.mark.parametrize(
+    ("by", "expected"),
+    [
+        ("_", {"s": [["foo bar"], ["foo", "bar"], ["foo", "bar", "baz"], ["foo,bar"]]}),
+        (
+            ",",
+            {"s": [["foo bar"], ["foo_bar"], ["foo_bar_baz"], ["foo", "bar"]]},
+        ),
+    ],
+)
+def test_str_split_series(
+    constructor_eager: ConstructorEager,
+    by: str,
+    expected: Any,
+) -> None:
+    if "cudf" not in str(constructor_eager) and (
+        constructor_eager.__name__.startswith("pandas")
+        and "pyarrow" not in constructor_eager.__name__
+    ):
+        df = nw.from_native(constructor_eager(data), eager_only=True)
+        msg = re.escape("This operation requires a pyarrow-backed series. ")
+        with pytest.raises(TypeError, match=msg):
+            df["s"].str.split(by=by)
+        return
+    df = nw.from_native(constructor_eager(data), eager_only=True)
+    result_series = df["s"].str.split(by=by)
+    assert_equal_data({"s": result_series}, expected)
diff --git a/tests/hypothesis/join_test.py b/tests/hypothesis/join_test.py
index 879d54ca..733e91a3 100644
--- a/tests/hypothesis/join_test.py
+++ b/tests/hypothesis/join_test.py
@@ -69,12 +69,12 @@ def test_join(  # pragma: no cover
 
     dframe_pd1 = nw.to_native(dframe_pl).to_pandas()
     dframe_pd1 = dframe_pd1.sort_values(
-        by=dframe_pd1.columns.to_list(), ignore_index=True
+        by=dframe_pd1.columns.to_list(), ignore_index=True, inplace=False
     )
 
     dframe_pd2 = nw.to_native(dframe_pd)
     dframe_pd2 = dframe_pd2.sort_values(
-        by=dframe_pd2.columns.to_list(), ignore_index=True
+        by=dframe_pd2.columns.to_list(), ignore_index=True, inplace=False
     )
 
     assert_frame_equal(dframe_pd1, dframe_pd2)
@@ -114,12 +114,12 @@ def test_cross_join(  # pragma: no cover
 
     dframe_pd1 = nw.to_native(dframe_pl).to_pandas()
     dframe_pd1 = dframe_pd1.sort_values(
-        by=dframe_pd1.columns.to_list(), ignore_index=True
+        by=dframe_pd1.columns.to_list(), ignore_index=True, inplace=False
     )
 
     dframe_pd2 = nw.to_native(dframe_pd)
     dframe_pd2 = dframe_pd2.sort_values(
-        by=dframe_pd2.columns.to_list(), ignore_index=True
+        by=dframe_pd2.columns.to_list(), ignore_index=True, inplace=False
     )
 
     assert_frame_equal(dframe_pd1, dframe_pd2)
diff --git a/tests/translate/from_native_test.py b/tests/translate/from_native_test.py
index 9ad0ec5b..7b48c8a7 100644
--- a/tests/translate/from_native_test.py
+++ b/tests/translate/from_native_test.py
@@ -3,6 +3,9 @@ from __future__ import annotations
 from contextlib import nullcontext as does_not_raise
 from typing import TYPE_CHECKING
 from typing import Any
+from typing import Iterable
+from typing import Literal
+from typing import cast
 
 import numpy as np
 import pandas as pd
@@ -241,7 +244,7 @@ def test_series_only_sqlframe() -> None:  # pragma: no cover
     df = session.createDataFrame([*zip(*data.values())], schema=[*data.keys()])
 
     with pytest.raises(TypeError, match="Cannot only use `series_only`"):
-        nw.from_native(df, series_only=True)
+        nw.from_native(df, series_only=True)  # pyright: ignore[reportArgumentType, reportCallIssue]
 
 
 @pytest.mark.parametrize(
@@ -287,3 +290,18 @@ def test_from_mock_interchange_protocol_non_strict() -> None:
     mockdf = MockDf()
     result = nw.from_native(mockdf, eager_only=True, strict=False)
     assert result is mockdf
+
+
+def test_from_native_altair_array_like() -> None:
+    obj: list[int] = [1, 2, 3, 4]
+    array_like = cast("Iterable[Any]", obj)
+    not_array_like: Literal[1] = 1
+
+    with pytest.raises(TypeError, match="got.+list"):
+        false_positive_native_series = nw.from_native(obj, series_only=True)  # noqa: F841
+
+    with pytest.raises(TypeError, match="got.+list"):
+        true_negative_iterable = nw.from_native(array_like, series_only=True)  # type: ignore[call-overload] # noqa: F841
+
+    with pytest.raises(TypeError, match="got.+int"):
+        true_negative_not_native_series = nw.from_native(not_array_like, series_only=True)  # type: ignore[call-overload] # noqa: F841
diff --git a/tests/utils.py b/tests/utils.py
index 65633e85..dc626df8 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -131,6 +131,10 @@ def assert_equal_data(result: Any, expected: Mapping[str, Any]) -> None:
                 are_equivalent_values = lhs is None or math.isnan(lhs)
             elif lhs is None:
                 are_equivalent_values = rhs is None
+            elif isinstance(lhs, list) and isinstance(rhs, list):
+                are_equivalent_values = all(
+                    left_side == right_side for left_side, right_side in zip(lhs, rhs)
+                )
             elif pd.isna(lhs):
                 are_equivalent_values = pd.isna(rhs)
             else: