Repository: narwhals-dev/narwhals

Issue:
Add temporal methods
SeCurrently, we have `Expr.dt.year`, and `Series.dt.year`

https://github.com/narwhals-dev/narwhals/blob/81e8f7959c66d0987324e83b33cf5a7b8e1c2b6e/tests/test_dt.py#L18-L26

We should also add:

- dt.millisecond
- dt.microsecond
- dt.nanosecond
- dt.total_seconds
- dt.total_milliseconds
- dt.toal_microseconds
- dt.total_nanoseconds

check the Polars docs for what these do

check https://github.com/narwhals-dev/narwhals/pull/120 for a reference PR. In that one, `ordinal_day` was the only one that wasn't implemented natively within pandas, so I had to work around it by going to the numpy level

Discussion:

Patch:
diff --git a/docs/api-reference/expressions_dt.md b/docs/api-reference/expressions_dt.md
index 955e3fe5..0ac984c1 100644
--- a/docs/api-reference/expressions_dt.md
+++ b/docs/api-reference/expressions_dt.md
@@ -5,5 +5,11 @@
     options:
       members:
         - year
+        - month
+        - day
+        - ordinal_day
+        - hour
+        - minute
+        - second
       show_source: false
       show_bases: false
diff --git a/docs/api-reference/series_dt.md b/docs/api-reference/series_dt.md
index f4c33214..7cfdd38f 100644
--- a/docs/api-reference/series_dt.md
+++ b/docs/api-reference/series_dt.md
@@ -5,5 +5,11 @@
     options:
       members:
         - year
+        - month
+        - day
+        - ordinal_day
+        - hour
+        - minute
+        - second
       show_source: false
       show_bases: false
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index b3825540..7c0c335e 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -294,3 +294,21 @@ class PandasExprDateTimeNamespace:
 
     def year(self) -> PandasExpr:
         return register_namespace_expression_call(self._expr, "dt", "year")
+
+    def month(self) -> PandasExpr:
+        return register_namespace_expression_call(self._expr, "dt", "month")
+
+    def day(self) -> PandasExpr:
+        return register_namespace_expression_call(self._expr, "dt", "day")
+
+    def hour(self) -> PandasExpr:
+        return register_namespace_expression_call(self._expr, "dt", "hour")
+
+    def minute(self) -> PandasExpr:
+        return register_namespace_expression_call(self._expr, "dt", "minute")
+
+    def second(self) -> PandasExpr:
+        return register_namespace_expression_call(self._expr, "dt", "second")
+
+    def ordinal_day(self) -> PandasExpr:
+        return register_namespace_expression_call(self._expr, "dt", "ordinal_day")
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index 5eb2e77c..1dbac0e3 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -452,3 +452,40 @@ class PandasSeriesDateTimeNamespace:
         return self._series._from_series(
             self._series._series.dt.year,
         )
+
+    def month(self) -> PandasSeries:
+        return self._series._from_series(
+            self._series._series.dt.month,
+        )
+
+    def day(self) -> PandasSeries:
+        return self._series._from_series(
+            self._series._series.dt.day,
+        )
+
+    def hour(self) -> PandasSeries:
+        return self._series._from_series(
+            self._series._series.dt.hour,
+        )
+
+    def minute(self) -> PandasSeries:
+        return self._series._from_series(
+            self._series._series.dt.minute,
+        )
+
+    def second(self) -> PandasSeries:
+        return self._series._from_series(
+            self._series._series.dt.second,
+        )
+
+    def ordinal_day(self) -> PandasSeries:
+        ser = self._series._series
+        year_start = ser.dt.year
+        result = (
+            ser.to_numpy().astype("datetime64[D]")
+            - (year_start.to_numpy() - 1970).astype("datetime64[Y]")
+        ).astype("int32") + 1
+        dtype = "Int64[pyarrow]" if "pyarrow" in str(ser.dtype) else "int32"
+        return self._series._from_series(
+            self._series._series.__class__(result, dtype=dtype, name=year_start.name)
+        )
diff --git a/narwhals/expression.py b/narwhals/expression.py
index 97f8e731..89d0742a 100644
--- a/narwhals/expression.py
+++ b/narwhals/expression.py
@@ -214,7 +214,7 @@ class Expr:
             >>> df_pd = pd.DataFrame({'a': [True, False], 'b': [True, True]})
             >>> df_pl = pl.DataFrame({'a': [True, False], 'b': [True, True]})
 
-            We define a data-frame agnostic function:
+            We define a dataframe-agnostic function:
 
             >>> def func(df_any):
             ...     df = nw.from_native(df_any)
@@ -903,7 +903,7 @@ class ExprStringNamespace:
             >>> df_pd = pd.DataFrame(data)
             >>> df_pl = pl.DataFrame(data)
 
-            We define a data-frame agnostic function:
+            We define a dataframe-agnostic function:
 
             >>> def func(df_any):
             ...     df = nw.from_native(df_any)
@@ -961,7 +961,7 @@ class ExprStringNamespace:
             >>> 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:
+            We define a dataframe-agnostic function:
 
             >>> def func(df_any):
             ...     df = nw.from_native(df_any)
@@ -997,6 +997,60 @@ class ExprDateTimeNamespace:
     def year(self) -> Expr:
         return self._expr.__class__(lambda plx: self._expr._call(plx).dt.year())
 
+    def month(self) -> Expr:
+        return self._expr.__class__(lambda plx: self._expr._call(plx).dt.month())
+
+    def day(self) -> Expr:
+        return self._expr.__class__(lambda plx: self._expr._call(plx).dt.day())
+
+    def hour(self) -> Expr:
+        return self._expr.__class__(lambda plx: self._expr._call(plx).dt.hour())
+
+    def minute(self) -> Expr:
+        return self._expr.__class__(lambda plx: self._expr._call(plx).dt.minute())
+
+    def second(self) -> Expr:
+        return self._expr.__class__(lambda plx: self._expr._call(plx).dt.second())
+
+    def ordinal_day(self) -> Expr:
+        """
+        Get ordinal day.
+
+        Examples:
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> from datetime import datetime
+            >>> import narwhals as nw
+            >>> data = {'a': [datetime(2020, 1, 1), datetime(2020, 8, 3)]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+
+            We define a dataframe-agnostic function:
+
+            >>> def func(df_any):
+            ...     df = nw.from_native(df_any)
+            ...     df = df.select(nw.col('a').dt.ordinal_day())
+            ...     return nw.to_native(df)
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(df_pd)
+                 a
+            0    1
+            1  216
+            >>> func(df_pl)
+            shape: (2, 1)
+            ┌─────┐
+            │ a   │
+            │ --- │
+            │ i16 │
+            ╞═════╡
+            │ 1   │
+            │ 216 │
+            └─────┘
+        """
+        return self._expr.__class__(lambda plx: self._expr._call(plx).dt.ordinal_day())
+
 
 def col(*names: str | Iterable[str]) -> Expr:
     """
diff --git a/narwhals/series.py b/narwhals/series.py
index 23ed00cd..73b6ee45 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -111,30 +111,6 @@ class Series:
         return self._series.min()
 
     def max(self) -> Any:
-        """
-        Get the maximum value in this Series.
-
-        Examples:
-            >>> import pandas as pd
-            >>> import polars as pl
-            >>> import narwhals as nw
-            >>> s = [1, 2, 3]
-            >>> s_pd = pd.Series(s)
-            >>> s_pl = pl.Series(s)
-
-            We define a library agnostic function:
-
-            >>> def func(s_any):
-            ...     s = nw.from_native(s_any, series_only=True)
-            ...     return s.max()
-
-            We can then pass either pandas or Polars to `func`:
-
-            >>> func(s_pd)
-            3
-            >>> func(s_pl)
-            3
-        """
         return self._series.max()
 
     def sum(self) -> Any:
@@ -204,7 +180,7 @@ class Series:
             >>> s_pd = pd.Series(s)
             >>> s_pl = pl.Series(s)
 
-            We define a data-frame agnostic function:
+            We define a dataframe-agnostic function:
 
             >>> def func(s_any):
             ...     s = nw.from_native(s_any, series_only=True)
@@ -255,7 +231,7 @@ class Series:
             >>> s_pd = pd.Series(s)
             >>> s_pl = pl.Series(s)
 
-            We define a data-frame agnostic function:
+            We define a dataframe-agnostic function:
 
             >>> def func(s_any):
             ...     s = nw.from_native(s_any, series_only=True)
@@ -375,7 +351,7 @@ class SeriesStringNamespace:
             >>> s_pd = pd.Series(lyrics)
             >>> s_pl = pl.Series(lyrics)
 
-            We define a data-frame agnostic function:
+            We define a dataframe-agnostic function:
 
             >>> def func(s_any):
             ...     s = nw.from_native(s_any, series_only=True)
@@ -411,3 +387,54 @@ class SeriesDateTimeNamespace:
 
     def year(self) -> Series:
         return self._series.__class__(self._series._series.dt.year())
+
+    def month(self) -> Series:
+        return self._series.__class__(self._series._series.dt.month())
+
+    def day(self) -> Series:
+        return self._series.__class__(self._series._series.dt.day())
+
+    def hour(self) -> Series:
+        return self._series.__class__(self._series._series.dt.hour())
+
+    def minute(self) -> Series:
+        return self._series.__class__(self._series._series.dt.minute())
+
+    def second(self) -> Series:
+        return self._series.__class__(self._series._series.dt.second())
+
+    def ordinal_day(self) -> Series:
+        """
+        Get ordinal day.
+
+        Examples:
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> from datetime import datetime
+            >>> import narwhals as nw
+            >>> data = [datetime(2020, 1, 1), datetime(2020, 8, 3)]
+            >>> s_pd = pd.Series(data)
+            >>> s_pl = pl.Series(data)
+
+            We define a library agnostic function:
+
+            >>> def func(s_any):
+            ...     s = nw.from_native(s_any, series_only=True)
+            ...     s = s.dt.ordinal_day()
+            ...     return nw.to_native(s)
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(s_pd)
+            0      1
+            1    216
+            dtype: int32
+            >>> func(s_pl)  # doctest: +NORMALIZE_WHITESPACE
+            shape: (2,)
+            Series: '' [i16]
+            [
+               1
+               216
+            ]
+        """
+        return self._series.__class__(self._series._series.dt.ordinal_day())


Test patch:
diff --git a/tests/test_dt.py b/tests/test_dt.py
index 3e099d3c..c9ecb713 100644
--- a/tests/test_dt.py
+++ b/tests/test_dt.py
@@ -3,24 +3,64 @@ from __future__ import annotations
 from datetime import datetime
 from typing import Any
 
+import hypothesis.strategies as st
 import pandas as pd
 import polars as pl
 import pytest
+from hypothesis import given
 
 import narwhals as nw
+from narwhals.utils import parse_version
 from tests.utils import compare_dicts
 
 data = {
-    "a": [datetime(2020, 1, 1), datetime(2020, 1, 2), datetime(2020, 1, 3)],
+    "a": [
+        datetime(2021, 3, 1, 12, 34, 56),
+        datetime(2020, 1, 2, 2, 4, 14),
+    ],
 }
 
 
 @pytest.mark.parametrize("constructor", [pd.DataFrame, pl.DataFrame])
-def test_dt_year(constructor: Any) -> None:
+@pytest.mark.parametrize(
+    ("attribute", "expected"),
+    [
+        ("year", [2021, 2020]),
+        ("month", [3, 1]),
+        ("day", [1, 2]),
+        ("hour", [12, 2]),
+        ("minute", [34, 4]),
+        ("second", [56, 14]),
+        ("ordinal_day", [60, 2]),
+    ],
+)
+def test_dt_year(attribute: str, expected: list[int], constructor: Any) -> None:
     df = nw.from_native(constructor(data), eager_only=True)
-    result = nw.to_native(df.select(nw.col("a").dt.year()))
-    expected = {"a": [2020, 2020, 2020]}
-    compare_dicts(result, expected)
-    result = nw.to_native(df.select(df["a"].dt.year()))
-    expected = {"a": [2020, 2020, 2020]}
-    compare_dicts(result, expected)
+    result = nw.to_native(df.select(getattr(nw.col("a").dt, attribute)()))
+    compare_dicts(result, {"a": expected})
+    result = nw.to_native(df.select(getattr(df["a"].dt, attribute)()))
+    compare_dicts(result, {"a": expected})
+
+
+@given(dates=st.datetimes(min_value=datetime(1960, 1, 1), max_value=datetime(1980, 1, 1)))  # type: ignore[misc]
+@pytest.mark.skipif(
+    parse_version(pd.__version__) < parse_version("2.0.0"),
+    reason="pyarrow dtype not available",
+)
+def test_ordinal_day(dates: datetime) -> None:
+    result_pd = nw.from_native(pd.Series([dates]), series_only=True).dt.ordinal_day()[0]
+    result_pdms = nw.from_native(
+        pd.Series([dates]).dt.as_unit("ms"), series_only=True
+    ).dt.ordinal_day()[0]
+    result_pda = nw.from_native(
+        pd.Series([dates]).convert_dtypes(dtype_backend="pyarrow"), series_only=True
+    ).dt.ordinal_day()[0]
+    result_pdn = nw.from_native(
+        pd.Series([dates]).convert_dtypes(dtype_backend="numpy_nullable"),
+        series_only=True,
+    ).dt.ordinal_day()[0]
+    result_pl = nw.from_native(pl.Series([dates]), series_only=True).dt.ordinal_day()[0]
+    assert result_pd == result_pl
+    assert result_pda == result_pl
+    assert result_pdn == result_pl
+    assert result_pdms == result_pl