Repository: narwhals-dev/narwhals
Pull request #2113 (2025-02-28 04:06:38)

enh: Adds support for `Time` datatype
<!--
# 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
- [ ] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [X] 🐳 Other

## Related issues

- Related issue #1989

## Checklist

- [X] Code follows style guide (ruff)
- [X] Tests added
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below
I have added support for the Polars Time datatype. I wanted to implement this first to ensure there are no issues before proceeding with the Binary datatype, as mentioned in the issue.

Please let me know if any changes or fixes are needed. Thank you!


marvinl803: > Thanks for the contribution @marvinl803 - This is a good start 🙏🏼
> 
> I would like to aim to support the following in this PR:
> 
> * Add a case test in [`tests/expr_and_series/cast_test.py`](https://github.com/narwhals-dev/narwhals/blob/main/tests/expr_and_series/cast_test.py)
> * Support more than just polars, as specified in [#1989 comment](https://github.com/narwhals-dev/narwhals/issues/1989#issuecomment-2658792772), pyarrow and duckdb should be possible to support
> * Make sure that conversion from and to the backend is supported

Hi @FBruzzesi,

I have added the test cases where you suggested and also implemented support for PyArrow and DuckDB. However, I encountered an issue in narwhals/dtypes.py—I wasn’t sure how to provide an example for the docs using DuckDB.

Additionally, I added Time to all the other supported types in narwhals_to_native_dtype, except for PyArrow. I’m uncertain whether we should return time32 or time64 in that case. Could you provide some guidance on how best to handle this?

I appreciate your help!
FBruzzesi: > I have added the test cases where you suggested and also implemented support for PyArrow and DuckDB. However, I encountered an issue in narwhals/dtypes.py—I wasn’t sure how to provide an example for the docs using DuckDB.

Thanks for your adjustments - I think we are very close to have this ready. Regarding the narwhals failing tests:

- One is because of coverage: conversion from narwhals to native has no test yet
- The other one if because of top level imports - try use `constructor` and mark unsupported backends as `xfail` rather than manually instantiating dataframes

> Additionally, I added Time to all the other supported types in narwhals_to_native_dtype, except for PyArrow. I’m uncertain whether we should return time32 or time64 in that case. Could you provide some guidance on how best to handle this?

I think we should convert narwhals Time to pyarrow time64 since that's what [polars Time](https://docs.pola.rs/api/python/stable/reference/api/polars.datatypes.Time.html#polars.datatypes.Time) mentions:

> _The underlying representation of this type is a 64-bit signed integer. The integer indicates the number of nanoseconds since midnight._

--- 

As a side note, we will need to make PRs in shiny and marimo to fix their failing tests 
FBruzzesi: @marvinl803 I added a few commits to reach coverage and add pandas support (if backed by pyarrow dtype).

I think this is ready to merge, but we should have ready the PRs to address marimo and shiny whenever we press the merge button. I will try to find a bit of time tomorrow (no promises 🙈)
marvinl803: > @marvinl803 I added a few commits to reach coverage and add pandas support (if backed by pyarrow dtype).
> 
> I think this is ready to merge, but we should have ready the PRs to address marimo and shiny whenever we press the merge button. I will try to find a bit of time tomorrow (no promises 🙈)
> 
> Plotly failure is unrelated, they simply reworked some deps files

Got it! Thanks for your help and the updates. I'm not too familiar with Marimo and Shiny, but happy to assist however I can. Let me know if there's anything I can do! 👍
FBruzzesi:  
> Got it! Thanks for your help and the updates, @FBruzzesi. I'm not too familiar with Marimo and Shiny, but happy to assist however I can. Let me know if there's anything I can do! 👍

Hey @marvinl803 I opened the one PR in each repo, and forgot to mention it. You should be able to see the link somewhere here (I am from mobile and it's tricky to find the links) 😊
marvinl803: > Hey @marvinl803 I opened one PR in each repo, and forgot to mention it. You should be able to see the link somewhere here (I am from mobile and it's tricky to find the links) 😊

No problem, @FBruzzesi! I found them—I'll check them out.
dangotbanned: 
> P.S. @dangotbanned I was not able to solve the mypy issues is CI 🥲

@FBruzzesi Let me take a quick look before merging 🤝 

Patch:
diff --git a/docs/api-reference/dtypes.md b/docs/api-reference/dtypes.md
index 3a49bcb3..cbfe117e 100644
--- a/docs/api-reference/dtypes.md
+++ b/docs/api-reference/dtypes.md
@@ -35,6 +35,7 @@
         - Object
         - Unknown
         - UnsignedIntegerType
+        - Time
       show_root_heading: false
       show_source: false
       show_bases: false
diff --git a/narwhals/__init__.py b/narwhals/__init__.py
index 9b257107..509e46ae 100644
--- a/narwhals/__init__.py
+++ b/narwhals/__init__.py
@@ -27,6 +27,7 @@ from narwhals.dtypes import List
 from narwhals.dtypes import Object
 from narwhals.dtypes import String
 from narwhals.dtypes import Struct
+from narwhals.dtypes import Time
 from narwhals.dtypes import UInt8
 from narwhals.dtypes import UInt16
 from narwhals.dtypes import UInt32
@@ -109,6 +110,7 @@ __all__ = [
     "Series",
     "String",
     "Struct",
+    "Time",
     "UInt8",
     "UInt16",
     "UInt32",
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index bc72bb46..5fc84a85 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -164,7 +164,7 @@ class ArrowExpr(EagerExpr["ArrowDataFrame", ArrowSeries]):
                 # again calling `sort_indices`. `ArrowSeries.scatter` would also sort.
                 sorting_indices = pc.sort_indices(df[token]._native_series)  # type: ignore[call-overload]
                 return [
-                    ser._from_native_series(pc.take(ser._native_series, sorting_indices))  # type: ignore[call-overload]
+                    ser._from_native_series(pc.take(ser._native_series, sorting_indices))
                     for ser in result
                 ]
         else:
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 45171f13..4642a6dc 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -674,7 +674,7 @@ class ArrowSeries(EagerSeries["ArrowChunkedArray"]):
 
         if value is not None:
             _, value = extract_native(self, value)
-            series = pc.fill_null(self.native, value)  # type: ignore[attr-defined]
+            series = pc.fill_null(self.native, value)
         elif limit is None:
             fill_func = (
                 pc.fill_null_forward if strategy == "forward" else pc.fill_null_backward
@@ -1100,19 +1100,11 @@ class ArrowSeries(EagerSeries["ArrowChunkedArray"]):
                 range_ = pc.subtract(upper, lower)
                 width = pc.divide(pc.cast(range_, pa_float), lit(float(bin_count)))
 
-            bin_proportions = pc.divide(
-                pc.subtract(cast("pc.NumericOrTemporalArray", self.native), lower),
-                width,
-            )
-            bin_indices: ArrowChunkedArray = cast(
-                "ArrowChunkedArray", pc.floor(bin_proportions)
-            )
-
-            # NOTE: stubs leave unannotated
-            if_else: Incomplete = pc.if_else
+            bin_proportions = pc.divide(pc.subtract(self.native, lower), width)
+            bin_indices = pc.floor(bin_proportions)
 
             # shift bins so they are right-closed
-            bin_indices = if_else(
+            bin_indices = pc.if_else(
                 pc.and_(
                     pc.equal(bin_indices, bin_proportions),
                     pc.greater(bin_indices, 0),
diff --git a/narwhals/_arrow/utils.py b/narwhals/_arrow/utils.py
index 7edbe473..b51609ab 100644
--- a/narwhals/_arrow/utils.py
+++ b/narwhals/_arrow/utils.py
@@ -154,6 +154,8 @@ def native_to_narwhals_dtype(dtype: pa.DataType, version: Version) -> DType:
         )
     if pa.types.is_decimal(dtype):
         return dtypes.Decimal()
+    if pa.types.is_time32(dtype) or pa.types.is_time64(dtype):
+        return dtypes.Time()
     return dtypes.Unknown()  # pragma: no cover
 
 
@@ -207,6 +209,8 @@ def narwhals_to_native_dtype(dtype: DType | type[DType], version: Version) -> pa
         inner = narwhals_to_native_dtype(dtype.inner, version=version)
         list_size = dtype.size
         return pa.list_(inner, list_size=list_size)
+    if isinstance_or_issubclass(dtype, dtypes.Time):
+        return pa.time64("ns")
 
     msg = f"Unknown dtype: {dtype}"  # pragma: no cover
     raise AssertionError(msg)
diff --git a/narwhals/_dask/utils.py b/narwhals/_dask/utils.py
index 3bacc328..6f9fc3b9 100644
--- a/narwhals/_dask/utils.py
+++ b/narwhals/_dask/utils.py
@@ -144,6 +144,9 @@ def narwhals_to_native_dtype(dtype: DType | type[DType], version: Version) -> An
     if isinstance_or_issubclass(dtype, dtypes.Array):  # pragma: no cover
         msg = "Converting to Array dtype is not supported yet"
         return NotImplementedError(msg)
+    if isinstance_or_issubclass(dtype, dtypes.Time):  # pragma: no cover
+        msg = "Converting to Time dtype is not supported yet"
+        return NotImplementedError(msg)
 
     msg = f"Unknown dtype: {dtype}"  # pragma: no cover
     raise AssertionError(msg)
diff --git a/narwhals/_duckdb/utils.py b/narwhals/_duckdb/utils.py
index b24b980b..99918cb0 100644
--- a/narwhals/_duckdb/utils.py
+++ b/narwhals/_duckdb/utils.py
@@ -114,6 +114,8 @@ def native_to_narwhals_dtype(duckdb_dtype: str, version: Version) -> DType:
         )
     if duckdb_dtype.startswith("DECIMAL("):
         return dtypes.Decimal()
+    if duckdb_dtype == "TIME":
+        return dtypes.Time()
     return dtypes.Unknown()  # pragma: no cover
 
 
@@ -150,6 +152,8 @@ def narwhals_to_native_dtype(dtype: DType | type[DType], version: Version) -> st
         return "VARCHAR"
     if isinstance_or_issubclass(dtype, dtypes.Boolean):  # pragma: no cover
         return "BOOLEAN"
+    if isinstance_or_issubclass(dtype, dtypes.Time):
+        return "TIME"
     if isinstance_or_issubclass(dtype, dtypes.Categorical):
         msg = "Categorical not supported by DuckDB"
         raise NotImplementedError(msg)
diff --git a/narwhals/_ibis/dataframe.py b/narwhals/_ibis/dataframe.py
index 7f69ce9a..96474b6a 100644
--- a/narwhals/_ibis/dataframe.py
+++ b/narwhals/_ibis/dataframe.py
@@ -69,6 +69,8 @@ def native_to_narwhals_dtype(ibis_dtype: Any, version: Version) -> DType:
     if ibis_dtype.is_decimal():  # pragma: no cover
         # TODO(unassigned): cover this
         return dtypes.Decimal()
+    if ibis_dtype.is_time():
+        return dtypes.Time()
     return dtypes.Unknown()  # pragma: no cover
 
 
diff --git a/narwhals/_pandas_like/utils.py b/narwhals/_pandas_like/utils.py
index 30bf6c18..2191c33a 100644
--- a/narwhals/_pandas_like/utils.py
+++ b/narwhals/_pandas_like/utils.py
@@ -392,6 +392,8 @@ def non_object_native_to_narwhals_dtype(dtype: str, version: Version) -> DType:
         return dtypes.Date()
     if dtype.startswith("decimal") and dtype.endswith("[pyarrow]"):
         return dtypes.Decimal()
+    if dtype.startswith("time") and dtype.endswith("[pyarrow]"):
+        return dtypes.Time()
     return dtypes.Unknown()  # pragma: no cover
 
 
@@ -590,7 +592,9 @@ def narwhals_to_native_dtype(  # noqa: PLR0915
     if isinstance_or_issubclass(dtype, dtypes.Enum):
         msg = "Converting to Enum is not (yet) supported"
         raise NotImplementedError(msg)
-    if isinstance_or_issubclass(dtype, (dtypes.Struct, dtypes.Array, dtypes.List)):
+    if isinstance_or_issubclass(
+        dtype, (dtypes.Struct, dtypes.Array, dtypes.List, dtypes.Time)
+    ):
         if implementation is Implementation.PANDAS and backend_version >= (2, 2):
             try:
                 import pandas as pd
diff --git a/narwhals/_polars/utils.py b/narwhals/_polars/utils.py
index f21649a6..c78580e3 100644
--- a/narwhals/_polars/utils.py
+++ b/narwhals/_polars/utils.py
@@ -145,6 +145,8 @@ def native_to_narwhals_dtype(
         )
     if dtype == pl.Decimal:
         return dtypes.Decimal()
+    if dtype == pl.Time:
+        return dtypes.Time()
     return dtypes.Unknown()
 
 
@@ -188,6 +190,8 @@ def narwhals_to_native_dtype(
         raise NotImplementedError(msg)
     if dtype == dtypes.Date:
         return pl.Date()
+    if dtype == dtypes.Time:
+        return pl.Time()
     if dtype == dtypes.Decimal:
         msg = "Casting to Decimal is not supported yet."
         raise NotImplementedError(msg)
diff --git a/narwhals/_spark_like/expr.py b/narwhals/_spark_like/expr.py
index f7190fc0..06d40c86 100644
--- a/narwhals/_spark_like/expr.py
+++ b/narwhals/_spark_like/expr.py
@@ -557,6 +557,21 @@ class SparkLikeExpr(LazyExpr["SparkLikeLazyFrame", "Column"]):
 
         return self._with_window_function(func)
 
+    def fill_null(
+        self,
+        value: Any | None,
+        strategy: Literal["forward", "backward"] | None,
+        limit: int | None,
+    ) -> Self:
+        if strategy is not None:
+            msg = "Support for strategies is not yet implemented."
+            raise NotImplementedError(msg)
+
+        def _fill_null(_input: Column, value: Column) -> Column:
+            return self._F.ifnull(_input, value)
+
+        return self._from_call(_fill_null, "fill_null", value=value)
+
     def rolling_sum(self, window_size: int, *, min_samples: int, center: bool) -> Self:
         if center:
             half = (window_size - 1) // 2
@@ -613,5 +628,4 @@ class SparkLikeExpr(LazyExpr["SparkLikeLazyFrame", "Column"]):
     cum_min = not_implemented()
     cum_max = not_implemented()
     cum_prod = not_implemented()
-    fill_null = not_implemented()
     quantile = not_implemented()
diff --git a/narwhals/_spark_like/utils.py b/narwhals/_spark_like/utils.py
index 5a3b159a..918aaafa 100644
--- a/narwhals/_spark_like/utils.py
+++ b/narwhals/_spark_like/utils.py
@@ -145,9 +145,10 @@ def narwhals_to_native_dtype(
             dtypes.UInt8,
             dtypes.Enum,
             dtypes.Categorical,
+            dtypes.Time,
         ),
     ):  # pragma: no cover
-        msg = "Unsigned integer, Enum and Categorical types are not supported by spark-like backend"
+        msg = "Unsigned integer, Enum, Categorical and Time types are not supported by spark-like backend"
         raise UnsupportedDTypeError(msg)
 
     msg = f"Unknown dtype: {dtype}"  # pragma: no cover
diff --git a/narwhals/dtypes.py b/narwhals/dtypes.py
index a0b0357c..28d04df3 100644
--- a/narwhals/dtypes.py
+++ b/narwhals/dtypes.py
@@ -671,3 +671,28 @@ class Date(TemporalType):
         >>> nw.from_native(s_native, series_only=True).dtype
         Date
     """
+
+
+class Time(TemporalType):
+    """Data type representing the time of day.
+
+    Examples:
+       >>> import polars as pl
+       >>> import pyarrow as pa
+       >>> import narwhals as nw
+       >>> import duckdb
+       >>> from datetime import time
+       >>> data = [time(9, 0), time(9, 1, 10), time(9, 2)]
+       >>> ser_pl = pl.Series(data)
+       >>> ser_pa = pa.chunked_array([pa.array(data, type=pa.time64("ns"))])
+       >>> rel = duckdb.sql(
+       ...     " SELECT * FROM (VALUES (TIME '12:00:00'), (TIME '14:30:15')) df(t)"
+       ... )
+
+       >>> nw.from_native(ser_pl, series_only=True).dtype
+       Time
+       >>> nw.from_native(ser_pa, series_only=True).dtype
+       Time
+       >>> nw.from_native(rel).schema["t"]
+       Time
+    """
diff --git a/narwhals/stable/v1/__init__.py b/narwhals/stable/v1/__init__.py
index f7d19b44..877d899c 100644
--- a/narwhals/stable/v1/__init__.py
+++ b/narwhals/stable/v1/__init__.py
@@ -57,6 +57,7 @@ from narwhals.stable.v1.dtypes import List
 from narwhals.stable.v1.dtypes import Object
 from narwhals.stable.v1.dtypes import String
 from narwhals.stable.v1.dtypes import Struct
+from narwhals.stable.v1.dtypes import Time
 from narwhals.stable.v1.dtypes import UInt8
 from narwhals.stable.v1.dtypes import UInt16
 from narwhals.stable.v1.dtypes import UInt32
@@ -2524,6 +2525,7 @@ __all__ = [
     "Series",
     "String",
     "Struct",
+    "Time",
     "UInt8",
     "UInt16",
     "UInt32",
diff --git a/narwhals/stable/v1/_dtypes.py b/narwhals/stable/v1/_dtypes.py
index f7813ec7..fdd6ec38 100644
--- a/narwhals/stable/v1/_dtypes.py
+++ b/narwhals/stable/v1/_dtypes.py
@@ -28,6 +28,7 @@ from narwhals.dtypes import Object
 from narwhals.dtypes import SignedIntegerType
 from narwhals.dtypes import String
 from narwhals.dtypes import Struct
+from narwhals.dtypes import Time
 from narwhals.dtypes import UInt8
 from narwhals.dtypes import UInt16
 from narwhals.dtypes import UInt32
@@ -97,6 +98,7 @@ __all__ = [
     "SignedIntegerType",
     "String",
     "Struct",
+    "Time",
     "UInt8",
     "UInt16",
     "UInt32",
diff --git a/narwhals/stable/v1/dtypes.py b/narwhals/stable/v1/dtypes.py
index f664c3e6..2a012d94 100644
--- a/narwhals/stable/v1/dtypes.py
+++ b/narwhals/stable/v1/dtypes.py
@@ -26,6 +26,7 @@ from narwhals.stable.v1._dtypes import Object
 from narwhals.stable.v1._dtypes import SignedIntegerType
 from narwhals.stable.v1._dtypes import String
 from narwhals.stable.v1._dtypes import Struct
+from narwhals.stable.v1._dtypes import Time
 from narwhals.stable.v1._dtypes import UInt8
 from narwhals.stable.v1._dtypes import UInt16
 from narwhals.stable.v1._dtypes import UInt32
@@ -61,6 +62,7 @@ __all__ = [
     "SignedIntegerType",
     "String",
     "Struct",
+    "Time",
     "UInt8",
     "UInt16",
     "UInt32",
diff --git a/narwhals/typing.py b/narwhals/typing.py
index 33d4712d..c19fba02 100644
--- a/narwhals/typing.py
+++ b/narwhals/typing.py
@@ -244,6 +244,7 @@ class DTypes:
     List: type[dtypes.List]
     Array: type[dtypes.Array]
     Unknown: type[dtypes.Unknown]
+    Time: type[dtypes.Time]
 
 
 __all__ = [


Test patch:
diff --git a/tests/dtypes_test.py b/tests/dtypes_test.py
index 79247ef2..d098b645 100644
--- a/tests/dtypes_test.py
+++ b/tests/dtypes_test.py
@@ -288,6 +288,7 @@ def test_dtype_is_x() -> None:
         nw.Object,
         nw.String,
         nw.Struct,
+        nw.Time,
         nw.UInt8,
         nw.UInt16,
         nw.UInt32,
@@ -300,7 +301,7 @@ def test_dtype_is_x() -> None:
     is_unsigned_integer = {nw.UInt8, nw.UInt16, nw.UInt32, nw.UInt64, nw.UInt128}
     is_float = {nw.Float32, nw.Float64}
     is_decimal = {nw.Decimal}
-    is_temporal = {nw.Datetime, nw.Date, nw.Duration}
+    is_temporal = {nw.Datetime, nw.Date, nw.Duration, nw.Time}
     is_nested = {nw.Array, nw.List, nw.Struct}
 
     for dtype in dtypes:
diff --git a/tests/expr_and_series/cast_test.py b/tests/expr_and_series/cast_test.py
index 09fa45c9..a49c3697 100644
--- a/tests/expr_and_series/cast_test.py
+++ b/tests/expr_and_series/cast_test.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 from datetime import datetime
+from datetime import time
 from datetime import timedelta
 from datetime import timezone
 from typing import Any
@@ -316,3 +317,16 @@ def test_raise_if_polars_dtype(constructor: Constructor, dtype: Any) -> None:
     df = nw.from_native(constructor({"a": [1, 2, 3], "b": [4, 5, 6]}))
     with pytest.raises(TypeError, match="Expected Narwhals dtype, got:"):
         df.select(nw.col("a").cast(dtype))
+
+
+def test_cast_time(request: pytest.FixtureRequest, constructor: Constructor) -> None:
+    if "pandas" in str(constructor) and PANDAS_VERSION < (2, 2):
+        request.applymarker(pytest.mark.xfail)
+
+    if any(backend in str(constructor) for backend in ("dask", "pyspark", "modin")):
+        request.applymarker(pytest.mark.xfail)
+
+    data = {"a": [time(12, 0, 0), time(12, 0, 5)]}
+    df = nw.from_native(constructor(data))
+    result = df.select(nw.col("a").cast(nw.Time()))
+    assert result.collect_schema() == {"a": nw.Time()}
diff --git a/tests/expr_and_series/fill_null_test.py b/tests/expr_and_series/fill_null_test.py
index a67351a6..2c04cbdf 100644
--- a/tests/expr_and_series/fill_null_test.py
+++ b/tests/expr_and_series/fill_null_test.py
@@ -12,9 +12,7 @@ from tests.utils import ConstructorEager
 from tests.utils import assert_equal_data
 
 
-def test_fill_null(request: pytest.FixtureRequest, constructor: Constructor) -> None:
-    if "pyspark" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_fill_null(constructor: Constructor) -> None:
     data = {
         "a": [0.0, None, 2.0, 3.0, 4.0],
         "b": [1.0, None, None, 5.0, 3.0],
@@ -31,11 +29,7 @@ def test_fill_null(request: pytest.FixtureRequest, constructor: Constructor) ->
     assert_equal_data(result, expected)
 
 
-def test_fill_null_series_expression(
-    request: pytest.FixtureRequest, constructor: Constructor
-) -> None:
-    if "pyspark" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_fill_null_series_expression(constructor: Constructor) -> None:
     data = {
         "a": [0.0, None, 2.0, 3.0, 4.0],
         "b": [1.0, None, None, 5.0, 3.0],
diff --git a/tests/series_only/is_ordered_categorical_test.py b/tests/series_only/is_ordered_categorical_test.py
index 547b951d..8681c50f 100644
--- a/tests/series_only/is_ordered_categorical_test.py
+++ b/tests/series_only/is_ordered_categorical_test.py
@@ -54,5 +54,8 @@ def test_is_definitely_not_ordered_categorical(
 @pytest.mark.xfail(reason="https://github.com/apache/arrow/issues/41017")
 def test_is_ordered_categorical_pyarrow() -> None:
     tp = pa.dictionary(pa.int32(), pa.string(), ordered=True)
-    s = pa.chunked_array([pa.array(["a", "b"], type=tp)])  # type: ignore[list-item]
-    assert nw.is_ordered_categorical(nw.from_native(s, series_only=True))
+    arr = pa.array(["a", "b"], type=tp)
+    s = pa.chunked_array([arr], type=tp)
+    assert nw.is_ordered_categorical(
+        nw.from_native(s, series_only=True)
+    )  # pragma: no cover