Repository: narwhals-dev/narwhals

Issue:
Feature: `Expr.str.to_datetime` 
I get that there's a bunch of expressions missing, but I might prioritise this one since it might allow me to do a bunch of featurization tricks in scikit-learn pipelines. 

Discussion:

Patch:
diff --git a/docs/api-reference/expressions_str.md b/docs/api-reference/expressions_str.md
index f8e0cecc..8067dd97 100644
--- a/docs/api-reference/expressions_str.md
+++ b/docs/api-reference/expressions_str.md
@@ -5,5 +5,6 @@
     options:
       members:
         - ends_with
+        - to_datetime
       show_source: false
       show_bases: false
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index b1a85f44..882417e6 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -6,6 +6,7 @@ from typing import Callable
 
 from narwhals._pandas_like.series import PandasSeries
 from narwhals._pandas_like.utils import register_expression_call
+from narwhals._pandas_like.utils import to_datetime
 
 if TYPE_CHECKING:
     from typing_extensions import Self
@@ -249,6 +250,23 @@ class PandasExprStringNamespace:
             implementation=self._expr._implementation,
         )
 
+    def to_datetime(self, format: str | None = None) -> PandasExpr:  # noqa: A002
+        # TODO make a register_expression_call for namespaces
+        return PandasExpr(
+            lambda df: [
+                PandasSeries(
+                    to_datetime(df._implementation)(series._series, format=format),
+                    implementation=df._implementation,
+                )
+                for series in self._expr._call(df)
+            ],
+            depth=self._expr._depth + 1,
+            function_name=f"{self._expr._function_name}->str.to_datetime",
+            root_names=self._expr._root_names,
+            output_names=self._expr._output_names,
+            implementation=self._expr._implementation,
+        )
+
 
 class PandasExprDateTimeNamespace:
     def __init__(self, expr: PandasExpr) -> None:
diff --git a/narwhals/_pandas_like/utils.py b/narwhals/_pandas_like/utils.py
index 4b4b154f..7cabfb8b 100644
--- a/narwhals/_pandas_like/utils.py
+++ b/narwhals/_pandas_like/utils.py
@@ -6,6 +6,9 @@ from typing import Any
 from typing import Iterable
 from typing import TypeVar
 
+from narwhals.dependencies import get_cudf
+from narwhals.dependencies import get_modin
+from narwhals.dependencies import get_pandas
 from narwhals.dependencies import get_pyarrow
 from narwhals.utils import flatten
 from narwhals.utils import isinstance_or_issubclass
@@ -369,3 +372,13 @@ def validate_indices(series: list[PandasSeries]) -> list[Any]:
         else:
             reindexed.append(s._series)
     return reindexed
+
+
+def to_datetime(implementation: str) -> Any:
+    if implementation == "pandas":
+        return get_pandas().to_datetime
+    if implementation == "modin":
+        return get_modin().to_datetime
+    if implementation == "cudf":
+        return get_cudf().to_datetime
+    raise AssertionError
diff --git a/narwhals/expression.py b/narwhals/expression.py
index addcb3e2..2754b2de 100644
--- a/narwhals/expression.py
+++ b/narwhals/expression.py
@@ -638,6 +638,51 @@ class ExprStringNamespace:
             lambda plx: self._expr._call(plx).str.ends_with(suffix)
         )
 
+    def to_datetime(self, format: str) -> Expr:  # noqa: A002
+        """
+        Convert to Datetime dtype.
+
+        Notes:
+            pandas defaults to nanosecond time unit, Polars to microsecond.
+            Prior to pandas 2.0, nanoseconds were the only time unit supported
+            in pandas, with no ability to set any other one. The ability to
+            set the time unit in pandas, if the version permits, will arrive.
+
+        Examples:
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import narwhals as nw
+            >>> df_pd = pd.DataFrame({'a': ['2020-01-01', '2020-01-02']})
+            >>> df_pl = pl.DataFrame({'a': ['2020-01-01', '2020-01-02']})
+
+            We define a data-frame agnostic function:
+
+            >>> def func(df_any):
+            ...     df = nw.from_native(df_any)
+            ...     df = df.select(nw.col('a').str.to_datetime(format='%Y-%m-%d'))
+            ...     return nw.to_native(df)
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(df_pd)
+                       a
+            0 2020-01-01
+            1 2020-01-02
+            >>> func(df_pl)
+            shape: (2, 1)
+            ┌─────────────────────┐
+            │ a                   │
+            │ ---                 │
+            │ datetime[μs]        │
+            ╞═════════════════════╡
+            │ 2020-01-01 00:00:00 │
+            │ 2020-01-02 00:00:00 │
+            └─────────────────────┘
+        """
+        return self._expr.__class__(
+            lambda plx: self._expr._call(plx).str.to_datetime(format=format)
+        )
+
 
 class ExprDateTimeNamespace:
     def __init__(self, expr: Expr) -> None:
diff --git a/narwhals/translate.py b/narwhals/translate.py
index caaad511..984b3d52 100644
--- a/narwhals/translate.py
+++ b/narwhals/translate.py
@@ -86,6 +86,17 @@ def from_native(
 ) -> Series: ...
 
 
+@overload
+def from_native(
+    native_dataframe: Any,
+    *,
+    strict: bool = ...,
+    eager_only: Literal[True],
+    series_only: None = ...,
+    allow_series: None = ...,
+) -> DataFrame: ...
+
+
 @overload
 def from_native(
     native_dataframe: Any,
diff --git a/pyproject.toml b/pyproject.toml
index 8956c5fc..272a34da 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -87,6 +87,8 @@ plugins = ["covdefaults"]
 exclude_also = [
   "> POLARS_VERSION",
   "if sys.version_info() <",
+  "if implementation == \"modin\"",
+  "if implementation == \"cudf\"",
 ]
 
 [tool.mypy]


Test patch:
diff --git a/tests/expr/__init__.py b/tests/expr/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/expr/test_to_datetime.py b/tests/expr/test_to_datetime.py
new file mode 100644
index 00000000..3c19df53
--- /dev/null
+++ b/tests/expr/test_to_datetime.py
@@ -0,0 +1,19 @@
+from datetime import datetime
+from typing import Any
+
+import pandas as pd
+import polars as pl
+import pytest
+
+import narwhals as nw
+
+df_pandas = pd.DataFrame({"a": ["2020-01-01T12:34:56"]})
+df_polars = pl.DataFrame({"a": ["2020-01-01T12:34:56"]})
+
+
+@pytest.mark.parametrize("df_any", [df_pandas, df_polars])
+def test_to_datetime(df_any: Any) -> None:
+    result = nw.from_native(df_any, eager_only=True).select(
+        b=nw.col("a").str.to_datetime(format="%Y-%m-%dT%H:%M:%S")
+    )["b"][0]
+    assert result == datetime(2020, 1, 1, 12, 34, 56)