Repository: narwhals-dev/narwhals
Pull request #291 (2024-06-11 21:45:59)

feat: `strftime` for datetime namespace
## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## 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.

-  `# noqa: A002` is because ruff complain about the "format" naming, but that's what polars uses.
- other comments in the code itself

FBruzzesi: Regarding CI failing:

```diff
-  "2020/03/01 00:00:00"
-  "2020/04/01 00:00:00"
-  "2020/05/01 00:00:00"
+  "2020/03/01 00:…
+  "2020/04/01 00:…
+  "2020/05/01 00:…
```
Not sure if... should I just trim the values?! 
MarcoGorelli: In Polars, there's `.dt.to_string`, which does the same thing. Polars also has `.dt.strftime` as an alias...it's one of the very few aliases in Polars.

I think here (Narwhals), because we're stricter, we should probably just pick one

For the reverse operation, we have `.str.to_datetime`, not `.str.strptime`. So, I think for this one, we should probably use `.dt.to_string`?

Patch:
diff --git a/docs/api-reference/expressions_dt.md b/docs/api-reference/expressions_dt.md
index bdd47191..9ace0bf4 100644
--- a/docs/api-reference/expressions_dt.md
+++ b/docs/api-reference/expressions_dt.md
@@ -19,5 +19,6 @@
         - total_milliseconds
         - total_microseconds
         - total_nanoseconds
+        - to_string
       show_source: false
       show_bases: false
diff --git a/docs/api-reference/series_dt.md b/docs/api-reference/series_dt.md
index f3b274d2..ad33a083 100644
--- a/docs/api-reference/series_dt.md
+++ b/docs/api-reference/series_dt.md
@@ -19,5 +19,6 @@
         - total_milliseconds
         - total_microseconds
         - total_nanoseconds
+        - to_string
       show_source: false
       show_bases: false
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index afe57e78..0be8b690 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -380,3 +380,8 @@ class PandasExprDateTimeNamespace:
         return reuse_series_namespace_implementation(
             self._expr, "dt", "total_nanoseconds"
         )
+
+    def to_string(self, format: str) -> PandasExpr:  # noqa: A002
+        return reuse_series_namespace_implementation(
+            self._expr, "dt", "to_string", format
+        )
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index ba8cfff6..eaa3a6f7 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -626,3 +626,6 @@ class PandasSeriesDateTimeNamespace:
         if ~s.isna().any():
             s_abs = s_abs.astype(int)
         return self._series._from_series(s_abs * s_sign)
+
+    def to_string(self, format: str) -> PandasSeries:  # noqa: A002
+        return self._series._from_series(self._series._series.dt.strftime(format))
diff --git a/narwhals/expression.py b/narwhals/expression.py
index b4c1ed24..2e439ba3 100644
--- a/narwhals/expression.py
+++ b/narwhals/expression.py
@@ -2297,6 +2297,53 @@ class ExprDateTimeNamespace:
             lambda plx: self._expr._call(plx).dt.total_nanoseconds()
         )
 
+    def to_string(self, format: str) -> Expr:  # noqa: A002
+        """
+        Convert a Date/Time/Datetime column into a String column with the given format.
+
+        Examples:
+            >>> from datetime import datetime
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import narwhals as nw
+            >>> data = [
+            ...     datetime(2020, 3, 1),
+            ...     datetime(2020, 4, 1),
+            ...     datetime(2020, 5, 1),
+            ... ]
+            >>> df_pd = pd.DataFrame({'a': data})
+            >>> df_pl = pl.DataFrame({'a': data})
+
+            We define a dataframe-agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(df):
+            ...     return df.select(nw.col('a').dt.to_string("%Y/%m/%d %H:%M:%S"))
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(df_pd)
+                                 a
+            0  2020/03/01 00:00:00
+            1  2020/04/01 00:00:00
+            2  2020/05/01 00:00:00
+
+            >>> func(df_pl)
+            shape: (3, 1)
+            ┌─────────────────────┐
+            │ a                   │
+            │ ---                 │
+            │ str                 │
+            ╞═════════════════════╡
+            │ 2020/03/01 00:00:00 │
+            │ 2020/04/01 00:00:00 │
+            │ 2020/05/01 00:00:00 │
+            └─────────────────────┘
+        """
+        return self._expr.__class__(
+            lambda plx: self._expr._call(plx).dt.to_string(format)
+        )
+
 
 def col(*names: str | Iterable[str]) -> Expr:
     """
diff --git a/narwhals/series.py b/narwhals/series.py
index 7bb33b4e..ebd6e716 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -2253,3 +2253,45 @@ class SeriesDateTimeNamespace:
             ]
         """
         return self._series.__class__(self._series._series.dt.total_nanoseconds())
+
+    def to_string(self, format: str) -> Series:  # noqa: A002
+        """
+        Convert a Date/Time/Datetime series into a String series with the given format.
+
+        Examples:
+            >>> from datetime import datetime
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import narwhals as nw
+            >>> data = [
+            ...     datetime(2020, 3, 1),
+            ...     datetime(2020, 4, 1),
+            ...     datetime(2020, 5, 1),
+            ... ]
+            >>> s_pd = pd.Series(data)
+            >>> s_pl = pl.Series(data)
+
+            We define a dataframe-agnostic function:
+
+            >>> @nw.narwhalify(allow_series=True)
+            ... def func(s):
+            ...     return s.dt.to_string("%Y/%m/%d")
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(s_pd)
+            0    2020/03/01
+            1    2020/04/01
+            2    2020/05/01
+            dtype: object
+
+            >>> func(s_pl)  # doctest: +NORMALIZE_WHITESPACE
+            shape: (3,)
+            Series: '' [str]
+            [
+               "2020/03/01"
+               "2020/04/01"
+               "2020/05/01"
+            ]
+        """
+        return self._series.__class__(self._series._series.dt.to_string(format))


Test patch:
diff --git a/tests/test_dt.py b/tests/test_dt.py
index 97a8d97c..cd4e47fb 100644
--- a/tests/test_dt.py
+++ b/tests/test_dt.py
@@ -166,3 +166,20 @@ def test_total_minutes(timedeltas: timedelta) -> None:
     assert result_pda == result_pl
     assert result_pdn == result_pl
     assert result_pdns == result_pl
+
+
+@pytest.mark.parametrize("constructor", [pd.DataFrame, pl.DataFrame])
+@pytest.mark.parametrize(
+    "fmt", ["%Y-%m-%d", "%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S", "%G-W%V-%u", "%G-W%V"]
+)
+def test_dt_to_string(constructor: Any, fmt: str) -> None:
+    input_frame = nw.from_native(constructor(data), eager_only=True)
+    input_series = input_frame["a"]
+
+    expected_col = [datetime.strftime(d, fmt) for d in data["a"]]
+
+    assert nw.to_native(input_series.dt.to_string(fmt)).to_list() == expected_col
+    assert (
+        nw.to_native(input_frame.select(nw.col("a").dt.to_string(fmt))["a"]).to_list()
+        == expected_col
+    )