Skip to content
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: 'v0.14.14'
rev: 'v0.15.0'
hooks:
# Run the formatter.
- id: ruff-format
Expand Down
2 changes: 1 addition & 1 deletion narwhals/_dask/expr.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ def _with_alias_output_names(self, func: AliasNames | None, /) -> Self:
def _with_binary(
self, call: Callable[[dx.Series, Any], dx.Series], other: Any
) -> Self:
return self._with_callable(lambda expr, other: call(expr, other), other=other)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😄 nice

return self._with_callable(call, other=other)

def _binary_op(self, op_name: str, other: Any) -> Self:
return self._with_binary(lambda expr, other: getattr(expr, op_name)(other), other)
Expand Down
8 changes: 5 additions & 3 deletions narwhals/_dask/expr_dt.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,11 @@ def to_string(self, format: str) -> DaskExpr:

def replace_time_zone(self, time_zone: str | None) -> DaskExpr:
return self.compliant._with_callable(
lambda expr: expr.dt.tz_localize(None).dt.tz_localize(time_zone)
if time_zone is not None
else expr.dt.tz_localize(None)
lambda expr: (
expr.dt.tz_localize(None).dt.tz_localize(time_zone)
if time_zone is not None
else expr.dt.tz_localize(None)
)
)

def convert_time_zone(self, time_zone: str) -> DaskExpr:
Expand Down
18 changes: 12 additions & 6 deletions narwhals/_duckdb/expr_dt.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,20 +58,26 @@ def total_minutes(self) -> DuckDBExpr:

def total_seconds(self) -> DuckDBExpr:
return self.compliant._with_elementwise(
lambda expr: lit(SECONDS_PER_MINUTE) * F("datepart", lit("minute"), expr)
+ F("datepart", lit("second"), expr)
lambda expr: (
lit(SECONDS_PER_MINUTE) * F("datepart", lit("minute"), expr)
+ F("datepart", lit("second"), expr)
)
)

def total_milliseconds(self) -> DuckDBExpr:
return self.compliant._with_elementwise(
lambda expr: lit(MS_PER_MINUTE) * F("datepart", lit("minute"), expr)
+ F("datepart", lit("millisecond"), expr)
lambda expr: (
lit(MS_PER_MINUTE) * F("datepart", lit("minute"), expr)
+ F("datepart", lit("millisecond"), expr)
)
)

def total_microseconds(self) -> DuckDBExpr:
return self.compliant._with_elementwise(
lambda expr: lit(US_PER_MINUTE) * F("datepart", lit("minute"), expr)
+ F("datepart", lit("microsecond"), expr)
lambda expr: (
lit(US_PER_MINUTE) * F("datepart", lit("minute"), expr)
+ F("datepart", lit("microsecond"), expr)
)
)

def truncate(self, every: str) -> DuckDBExpr:
Expand Down
20 changes: 14 additions & 6 deletions tests/dtypes/dtypes_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@

import narwhals as nw
from narwhals.exceptions import InvalidOperationError, PerformanceWarning
from tests.utils import PANDAS_VERSION, POLARS_VERSION, PYARROW_VERSION, pyspark_session
from tests.utils import (
PANDAS_VERSION,
POLARS_VERSION,
PYARROW_VERSION,
assert_equal_hash,
pyspark_session,
)

if TYPE_CHECKING:
from collections.abc import Iterable
Expand Down Expand Up @@ -68,7 +74,7 @@ def test_list_valid() -> None:
assert dtype == nw.List(nw.List(nw.Int64))
assert dtype == nw.List
assert dtype != nw.List(nw.List(nw.Float32))
assert dtype in {nw.List(nw.List(nw.Int64))}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are we sure about changing this? wouldn't we still want to test that assert dtype in {nw.List(nw.List(nw.Int64))} passes?

like this it feels like we're testing implementation details, it may be better to just noqa it?

Copy link
Member Author

@dangotbanned dangotbanned Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about a function (with whatever in it), that has a name more fitting to the test?

E.g.

-    assert dtype in {nw.List(nw.List(nw.Int64))}
+    assert_hash_equals(dtype, nw.List(nw.List(nw.Int64)))

I actually applied the auto-fix first, before realizing it changed the thing we test.

That seems like a pretty easy mistake to make and I might not have caught it without knowing the implementation detail 😅

Edit:
How easy it is to make that mistake?

Well I managed to do it in the same commit by only getting the first one right 🤦‍♂️

narwhals/tests/v1_test.py

Lines 534 to 537 in b144f1e

assert hash(dtype) == hash(nw_v1.Datetime)
assert isinstance(dtype, nw_v1.Datetime)
dtype = df.lazy().schema["c"]
assert dtype == nw_v1.Duration

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure!

Copy link
Member Author

@dangotbanned dangotbanned Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hopefully this'll do 🙂 (6d5e3d5)

>       assert_equal_hash(dtype, nw.Enum(["a", "b", "c"]))
E       AssertionError: inputs do not compare equal by `__hash__`
E       [left]: Enum(categories=['a', 'b'])
E       [right]: Enum(categories=['a', 'b', 'c'])

tests/dtypes/dtypes_test.py:548: AssertionError

Based on what's in narwhals.tests

assert_equal_hash(dtype, nw.List(nw.List(nw.Int64)))


def test_array_valid() -> None:
Expand All @@ -83,7 +89,7 @@ def test_array_valid() -> None:
assert dtype == nw.Array(nw.Array(nw.Int64, 2), 2)
assert dtype == nw.Array
assert dtype != nw.Array(nw.Array(nw.Float32, 2), 2)
assert dtype in {nw.Array(nw.Array(nw.Int64, 2), 2)}
assert_equal_hash(dtype, nw.Array(nw.Array(nw.Int64, 2), 2))

with pytest.raises(TypeError, match="invalid input for shape"):
nw.Array(nw.Int64(), shape=None) # type: ignore[arg-type]
Expand All @@ -105,7 +111,7 @@ def test_struct_valid() -> None:
assert dtype.to_schema() == nw.Struct({"a": nw.Int64, "b": nw.String}).to_schema()
assert dtype == nw.Struct
assert dtype != nw.Struct({"a": nw.Int32, "b": nw.String})
assert dtype in {nw.Struct({"a": nw.Int64, "b": nw.String})}
assert_equal_hash(dtype, nw.Struct({"a": nw.Int64, "b": nw.String}))


def test_struct_reverse() -> None:
Expand Down Expand Up @@ -535,8 +541,10 @@ def test_enum_repr() -> None:


def test_enum_hash() -> None:
assert nw.Enum(["a", "b"]) in {nw.Enum(["a", "b"])}
assert nw.Enum(["a", "b"]) not in {nw.Enum(["a", "b", "c"])}
dtype = nw.Enum(["a", "b"])
assert_equal_hash(dtype, nw.Enum(["a", "b"]))
with pytest.raises(AssertionError):
assert_equal_hash(dtype, nw.Enum(["a", "b", "c"]))


@pytest.mark.xfail(
Expand Down
2 changes: 1 addition & 1 deletion tests/stable_api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def test_stable_api_completeness() -> None:
def test_stable_api_docstrings() -> None:
main_namespace_api = nw.__all__
for item in main_namespace_api:
if item in {"from_dict"}:
if item == "from_dict":
# We keep `native_namespace` around in the main namespace
# until at least hierarchical forecast make a release
continue
Expand Down
8 changes: 8 additions & 0 deletions tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,14 @@ def assert_equal_series(
assert_equal_data(result.to_frame(), {name: expected})


def assert_equal_hash(left: Any, right: Any) -> None:
"""Assert that the left and right produce identical hash values."""
__tracebackhide__ = True
assert left in {right}, ( # noqa: FURB171
f"inputs do not compare equal by `__hash__`\n[left]: {left}\n[right]: {right}"
)


def sqlframe_session() -> DuckDBSession:
from sqlframe.duckdb import DuckDBSession

Expand Down
5 changes: 3 additions & 2 deletions tests/v1_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
Constructor,
ConstructorEager,
assert_equal_data,
assert_equal_hash,
assert_equal_series,
)

Expand Down Expand Up @@ -531,10 +532,10 @@ def test_dtypes() -> None:
pd.DataFrame({"a": [1], "b": [datetime(2020, 1, 1)], "c": [timedelta(1)]})
)
dtype = df.collect_schema()["b"]
assert dtype in {nw_v1.Datetime}
assert_equal_hash(dtype, nw_v1.Datetime)
assert isinstance(dtype, nw_v1.Datetime)
dtype = df.lazy().schema["c"]
assert dtype in {nw_v1.Duration}
assert_equal_hash(dtype, nw_v1.Duration)
assert isinstance(dtype, nw_v1.Duration)


Expand Down
Loading