Repository: narwhals-dev/narwhals
Pull request #735 (2024-08-07 13:47:48)

feat: dask expr name namespace
## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues 

- Related issue #637 

## 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.

My favorite namespace 😂


Patch:
diff --git a/docs/api-reference/expr_dt.md b/docs/api-reference/expr_dt.md
index af929486..e04e9288 100644
--- a/docs/api-reference/expr_dt.md
+++ b/docs/api-reference/expr_dt.md
@@ -4,6 +4,7 @@
     handler: python
     options:
       members:
+        - date
         - year
         - month
         - day
diff --git a/docs/api-reference/series_dt.md b/docs/api-reference/series_dt.md
index ad33a083..ba342ad3 100644
--- a/docs/api-reference/series_dt.md
+++ b/docs/api-reference/series_dt.md
@@ -4,6 +4,7 @@
     handler: python
     options:
       members:
+        - date
         - year
         - month
         - day
diff --git a/docs/installation.md b/docs/installation.md
index 467ed963..5ef6df18 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -11,6 +11,6 @@ Then, if you start the Python REPL and see the following:
 ```python
 >>> import narwhals
 >>> narwhals.__version__
-'1.1.9'
+'1.2.0'
 ```
 then installation worked correctly!
diff --git a/narwhals/__init__.py b/narwhals/__init__.py
index 4bbe47ab..6bbed854 100644
--- a/narwhals/__init__.py
+++ b/narwhals/__init__.py
@@ -48,7 +48,7 @@ from narwhals.utils import maybe_align_index
 from narwhals.utils import maybe_convert_dtypes
 from narwhals.utils import maybe_set_index
 
-__version__ = "1.1.9"
+__version__ = "1.2.0"
 
 __all__ = [
     "selectors",
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index a539040f..25400dc7 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -287,6 +287,13 @@ class ArrowExpr:
     def gather_every(self: Self, n: int, offset: int = 0) -> Self:
         return reuse_series_implementation(self, "gather_every", n=n, offset=offset)
 
+    def clip(
+        self: Self, lower_bound: Any | None = None, upper_bound: Any | None = None
+    ) -> Self:
+        return reuse_series_implementation(
+            self, "clip", lower_bound=lower_bound, upper_bound=upper_bound
+        )
+
     def over(self: Self, keys: list[str]) -> Self:
         def func(df: ArrowDataFrame) -> list[ArrowSeries]:
             if self._output_names is None:
@@ -347,6 +354,9 @@ class ArrowExprDateTimeNamespace:
             self._expr, "dt", "to_string", format
         )
 
+    def date(self: Self) -> ArrowExpr:
+        return reuse_series_namespace_implementation(self._expr, "dt", "date")
+
     def year(self: Self) -> ArrowExpr:
         return reuse_series_namespace_implementation(self._expr, "dt", "year")
 
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 3c13be2d..bd50c030 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -587,6 +587,18 @@ class ArrowSeries:
     def gather_every(self: Self, n: int, offset: int = 0) -> Self:
         return self._from_native_series(self._native_series[offset::n])
 
+    def clip(
+        self: Self, lower_bound: Any | None = None, upper_bound: Any | None = None
+    ) -> Self:
+        pa = get_pyarrow()
+        pc = get_pyarrow_compute()
+
+        arr = self._native_series
+        arr = pc.max_element_wise(arr, pa.scalar(lower_bound, type=arr.type))
+        arr = pc.min_element_wise(arr, pa.scalar(upper_bound, type=arr.type))
+
+        return self._from_native_series(arr)
+
     @property
     def shape(self) -> tuple[int]:
         return (len(self._native_series),)
@@ -618,6 +630,12 @@ class ArrowSeriesDateTimeNamespace:
             pc.strftime(self._arrow_series._native_series, format)
         )
 
+    def date(self: Self) -> ArrowSeries:
+        pa = get_pyarrow()
+        return self._arrow_series._from_native_series(
+            self._arrow_series._native_series.cast(pa.date64())
+        )
+
     def year(self: Self) -> ArrowSeries:
         pc = get_pyarrow_compute()
         return self._arrow_series._from_native_series(
diff --git a/narwhals/_dask/expr.py b/narwhals/_dask/expr.py
index 4e5a5040..27a574a6 100644
--- a/narwhals/_dask/expr.py
+++ b/narwhals/_dask/expr.py
@@ -149,6 +149,14 @@ class DaskExpr:
             returns_scalar=False,
         )
 
+    def __radd__(self, other: Any) -> Self:
+        return self._from_call(
+            lambda _input, other: _input.__radd__(other),
+            "__radd__",
+            other,
+            returns_scalar=False,
+        )
+
     def __sub__(self, other: Any) -> Self:
         return self._from_call(
             lambda _input, other: _input.__sub__(other),
@@ -157,6 +165,14 @@ class DaskExpr:
             returns_scalar=False,
         )
 
+    def __rsub__(self, other: Any) -> Self:
+        return self._from_call(
+            lambda _input, other: _input.__rsub__(other),
+            "__rsub__",
+            other,
+            returns_scalar=False,
+        )
+
     def __mul__(self, other: Any) -> Self:
         return self._from_call(
             lambda _input, other: _input.__mul__(other),
@@ -165,6 +181,78 @@ class DaskExpr:
             returns_scalar=False,
         )
 
+    def __rmul__(self, other: Any) -> Self:
+        return self._from_call(
+            lambda _input, other: _input.__rmul__(other),
+            "__rmul__",
+            other,
+            returns_scalar=False,
+        )
+
+    def __truediv__(self, other: Any) -> Self:
+        return self._from_call(
+            lambda _input, other: _input.__truediv__(other),
+            "__truediv__",
+            other,
+            returns_scalar=False,
+        )
+
+    def __rtruediv__(self, other: Any) -> Self:
+        return self._from_call(
+            lambda _input, other: _input.__rtruediv__(other),
+            "__rtruediv__",
+            other,
+            returns_scalar=False,
+        )
+
+    def __floordiv__(self, other: Any) -> Self:
+        return self._from_call(
+            lambda _input, other: _input.__floordiv__(other),
+            "__floordiv__",
+            other,
+            returns_scalar=False,
+        )
+
+    def __rfloordiv__(self, other: Any) -> Self:
+        return self._from_call(
+            lambda _input, other: _input.__rfloordiv__(other),
+            "__rfloordiv__",
+            other,
+            returns_scalar=False,
+        )
+
+    def __pow__(self, other: Any) -> Self:
+        return self._from_call(
+            lambda _input, other: _input.__pow__(other),
+            "__pow__",
+            other,
+            returns_scalar=False,
+        )
+
+    def __rpow__(self, other: Any) -> Self:
+        return self._from_call(
+            lambda _input, other: _input.__rpow__(other),
+            "__rpow__",
+            other,
+            returns_scalar=False,
+        )
+
+    def __mod__(self, other: Any) -> Self:
+        return self._from_call(
+            lambda _input, other: _input.__mod__(other),
+            "__mod__",
+            other,
+            returns_scalar=False,
+        )
+
+    def __rmod__(self, other: Any) -> Self:
+        return self._from_call(
+            lambda _input, other: _input.__rmod__(other),
+            "__rmod__",
+            other,
+            returns_scalar=False,
+        )
+
     def __eq__(self, other: DaskExpr) -> Self:  # type: ignore[override]
         return self._from_call(
             lambda _input, other: _input.__eq__(other),
@@ -221,6 +309,14 @@ class DaskExpr:
             returns_scalar=False,
         )
 
+    def __rand__(self, other: DaskExpr) -> Self:  # pragma: no cover
+        return self._from_call(
+            lambda _input, other: _input.__rand__(other),
+            "__rand__",
+            other,
+            returns_scalar=False,
+        )
+
     def __or__(self, other: DaskExpr) -> Self:
         return self._from_call(
             lambda _input, other: _input.__or__(other),
@@ -229,6 +325,14 @@ class DaskExpr:
             returns_scalar=False,
         )
 
+    def __ror__(self, other: DaskExpr) -> Self:  # pragma: no cover
+        return self._from_call(
+            lambda _input, other: _input.__ror__(other),
+            "__ror__",
+            other,
+            returns_scalar=False,
+        )
+
     def mean(self) -> Self:
         return self._from_call(
             lambda _input: _input.mean(),
@@ -250,6 +354,14 @@ class DaskExpr:
             returns_scalar=True,
         )
 
+    def std(self, ddof: int = 1) -> Self:
+        return self._from_call(
+            lambda _input, ddof: _input.std(ddof=ddof),
+            "std",
+            ddof,
+            returns_scalar=True,
+        )
+
     def shift(self, n: int) -> Self:
         return self._from_call(
             lambda _input, n: _input.shift(n),
@@ -309,6 +421,17 @@ class DaskExpr:
             returns_scalar=False,
         )
 
+    def clip(
+        self: Self, lower_bound: Any | None = None, upper_bound: Any | None = None
+    ) -> Self:
+        return self._from_call(
+            lambda _input, _lower, _upper: _input.clip(lower=_lower, upper=_upper),
+            "clip",
+            lower_bound,
+            upper_bound,
+            returns_scalar=False,
+        )
+
     @property
     def str(self: Self) -> DaskExprStringNamespace:
         return DaskExprStringNamespace(self)
@@ -317,6 +440,10 @@ class DaskExpr:
     def dt(self: Self) -> DaskExprDateTimeNamespace:
         return DaskExprDateTimeNamespace(self)
 
+    @property
+    def name(self: Self) -> DaskExprNameNamespace:
+        return DaskExprNameNamespace(self)
+
 
 class DaskExprStringNamespace:
     def __init__(self, expr: DaskExpr) -> None:
@@ -392,6 +519,13 @@ class DaskExprDateTimeNamespace:
     def __init__(self, expr: DaskExpr) -> None:
         self._expr = expr
 
+    def date(self) -> DaskExpr:
+        return self._expr._from_call(
+            lambda _input: _input.dt.date,
+            "date",
+            returns_scalar=False,
+        )
+
     def year(self) -> DaskExpr:
         return self._expr._from_call(
             lambda _input: _input.dt.year,
@@ -461,3 +595,157 @@ class DaskExprDateTimeNamespace:
             "ordinal_day",
             returns_scalar=False,
         )
+
+
+class DaskExprNameNamespace:
+    def __init__(self: Self, expr: DaskExpr) -> None:
+        self._expr = expr
+
+    def keep(self: Self) -> DaskExpr:
+        root_names = self._expr._root_names
+
+        if root_names is None:
+            msg = (
+                "Anonymous expressions are not supported in `.name.keep`.\n"
+                "Instead of `nw.all()`, try using a named expression, such as "
+                "`nw.col('a', 'b')`\n"
+            )
+            raise ValueError(msg)
+
+        return self._expr.__class__(
+            lambda df: [
+                series.rename(name)
+                for series, name in zip(self._expr._call(df), root_names)
+            ],
+            depth=self._expr._depth,
+            function_name=self._expr._function_name,
+            root_names=root_names,
+            output_names=root_names,
+            returns_scalar=self._expr._returns_scalar,
+            backend_version=self._expr._backend_version,
+        )
+
+    def map(self: Self, function: Callable[[str], str]) -> DaskExpr:
+        root_names = self._expr._root_names
+
+        if root_names is None:
+            msg = (
+                "Anonymous expressions are not supported in `.name.map`.\n"
+                "Instead of `nw.all()`, try using a named expression, such as "
+                "`nw.col('a', 'b')`\n"
+            )
+            raise ValueError(msg)
+
+        output_names = [function(str(name)) for name in root_names]
+
+        return self._expr.__class__(
+            lambda df: [
+                series.rename(name)
+                for series, name in zip(self._expr._call(df), output_names)
+            ],
+            depth=self._expr._depth,
+            function_name=self._expr._function_name,
+            root_names=root_names,
+            output_names=output_names,
+            returns_scalar=self._expr._returns_scalar,
+            backend_version=self._expr._backend_version,
+        )
+
+    def prefix(self: Self, prefix: str) -> DaskExpr:
+        root_names = self._expr._root_names
+        if root_names is None:
+            msg = (
+                "Anonymous expressions are not supported in `.name.prefix`.\n"
+                "Instead of `nw.all()`, try using a named expression, such as "
+                "`nw.col('a', 'b')`\n"
+            )
+            raise ValueError(msg)
+
+        output_names = [prefix + str(name) for name in root_names]
+        return self._expr.__class__(
+            lambda df: [
+                series.rename(name)
+                for series, name in zip(self._expr._call(df), output_names)
+            ],
+            depth=self._expr._depth,
+            function_name=self._expr._function_name,
+            root_names=root_names,
+            output_names=output_names,
+            returns_scalar=self._expr._returns_scalar,
+            backend_version=self._expr._backend_version,
+        )
+
+    def suffix(self: Self, suffix: str) -> DaskExpr:
+        root_names = self._expr._root_names
+        if root_names is None:
+            msg = (
+                "Anonymous expressions are not supported in `.name.suffix`.\n"
+                "Instead of `nw.all()`, try using a named expression, such as "
+                "`nw.col('a', 'b')`\n"
+            )
+            raise ValueError(msg)
+
+        output_names = [str(name) + suffix for name in root_names]
+
+        return self._expr.__class__(
+            lambda df: [
+                series.rename(name)
+                for series, name in zip(self._expr._call(df), output_names)
+            ],
+            depth=self._expr._depth,
+            function_name=self._expr._function_name,
+            root_names=root_names,
+            output_names=output_names,
+            returns_scalar=self._expr._returns_scalar,
+            backend_version=self._expr._backend_version,
+        )
+
+    def to_lowercase(self: Self) -> DaskExpr:
+        root_names = self._expr._root_names
+
+        if root_names is None:
+            msg = (
+                "Anonymous expressions are not supported in `.name.to_lowercase`.\n"
+                "Instead of `nw.all()`, try using a named expression, such as "
+                "`nw.col('a', 'b')`\n"
+            )
+            raise ValueError(msg)
+        output_names = [str(name).lower() for name in root_names]
+
+        return self._expr.__class__(
+            lambda df: [
+                series.rename(name)
+                for series, name in zip(self._expr._call(df), output_names)
+            ],
+            depth=self._expr._depth,
+            function_name=self._expr._function_name,
+            root_names=root_names,
+            output_names=output_names,
+            returns_scalar=self._expr._returns_scalar,
+            backend_version=self._expr._backend_version,
+        )
+
+    def to_uppercase(self: Self) -> DaskExpr:
+        root_names = self._expr._root_names
+
+        if root_names is None:
+            msg = (
+                "Anonymous expressions are not supported in `.name.to_uppercase`.\n"
+                "Instead of `nw.all()`, try using a named expression, such as "
+                "`nw.col('a', 'b')`\n"
+            )
+            raise ValueError(msg)
+        output_names = [str(name).upper() for name in root_names]
+
+        return self._expr.__class__(
+            lambda df: [
+                series.rename(name)
+                for series, name in zip(self._expr._call(df), output_names)
+            ],
+            depth=self._expr._depth,
+            function_name=self._expr._function_name,
+            root_names=root_names,
+            output_names=output_names,
+            returns_scalar=self._expr._returns_scalar,
+            backend_version=self._expr._backend_version,
+        )
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index 5231412e..c978e26f 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -436,6 +436,9 @@ class PandasLikeExprDateTimeNamespace:
     def __init__(self, expr: PandasLikeExpr) -> None:
         self._expr = expr
 
+    def date(self) -> PandasLikeExpr:
+        return reuse_series_namespace_implementation(self._expr, "dt", "date")
+
     def year(self) -> PandasLikeExpr:
         return reuse_series_namespace_implementation(self._expr, "dt", "year")
 
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index acd99bf5..e52a034e 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -716,6 +716,21 @@ class PandasLikeSeriesDateTimeNamespace:
     def __init__(self, series: PandasLikeSeries) -> None:
         self._pandas_series = series
 
+    def date(self) -> PandasLikeSeries:
+        result = self._pandas_series._from_native_series(
+            self._pandas_series._native_series.dt.date,
+        )
+        if str(result.dtype).lower() == "object":
+            msg = (
+                "Accessing `date` on the default pandas backend "
+                "will return a Series of type `object`."
+                "\nThis differs from polars API and will prevent `.dt` chaining. "
+                "Please switch to the `pyarrow` backend:"
+                '\ndf.convert_dtypes(dtype_backend="pyarrow")'
+            )
+            raise NotImplementedError(msg)
+        return result
+
     def year(self) -> PandasLikeSeries:
         return self._pandas_series._from_native_series(
             self._pandas_series._native_series.dt.year,
diff --git a/narwhals/expr.py b/narwhals/expr.py
index 27185a6e..e51bd267 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -1868,11 +1868,11 @@ class Expr:
             │ i64 │
             ╞═════╡
             │ -1  │
-            │ 1   │
+            │  1  │
             │ -1  │
-            │ 3   │
+            │  3  │
             │ -1  │
-            │ 3   │
+            │  3  │
             └─────┘
         """
         return self.__class__(
@@ -2441,6 +2441,47 @@ class ExprDateTimeNamespace:
     def __init__(self, expr: Expr) -> None:
         self._expr = expr
 
+    def date(self) -> Expr:
+        """
+        Extract the date from underlying DateTime representation.
+
+        Raises:
+            NotImplementedError: If pandas default backend is being used.
+
+        Examples:
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> from datetime import datetime
+            >>> import narwhals as nw
+            >>> dates = [datetime(2012, 1, 7, 10, 20), datetime(2023, 3, 10, 11, 32)]
+            >>> s_pd = pd.Series(dates).convert_dtypes(
+            ...     dtype_backend="pyarrow"
+            ... )  # doctest:+SKIP
+            >>> s_pl = pl.Series(dates)
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(s):
+            ...     return s.dt.date()
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(s_pd)  # doctest:+SKIP
+            0    2012-01-07
+            1    2023-03-10
+            dtype: date32[day][pyarrow]
+
+            >>> func(s_pl)  # doctest: +NORMALIZE_WHITESPACE
+            shape: (2,)
+            Series: '' [date]
+            [
+               2012-01-07
+               2023-03-10
+            ]
+        """
+        return self._expr.__class__(lambda plx: self._expr._call(plx).dt.date())
+
     def year(self) -> Expr:
         """
         Extract year from underlying DateTime representation.
diff --git a/narwhals/series.py b/narwhals/series.py
index b8e98cd7..8027cacf 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -634,11 +634,11 @@ class Series:
             Series: '' [i64]
             [
                -1
-               1
+                1
                -1
-               3
+                3
                -1
-               3
+                3
             ]
         """
         return self._from_compliant_series(
@@ -2678,6 +2678,49 @@ class SeriesDateTimeNamespace:
     def __init__(self, series: Series) -> None:
         self._narwhals_series = series
 
+    def date(self) -> Series:
+        """
+        Get the date in a datetime series.
+
+        Raises:
+            NotImplementedError: If pandas default backend is being used.
+
+        Examples:
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> from datetime import datetime
+            >>> import narwhals as nw
+            >>> dates = [datetime(2012, 1, 7, 10, 20), datetime(2023, 3, 10, 11, 32)]
+            >>> s_pd = pd.Series(dates).convert_dtypes(
+            ...     dtype_backend="pyarrow"
+            ... )  # doctest:+SKIP
+            >>> s_pl = pl.Series(dates)
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(s):
+            ...     return s.dt.date()
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(s_pd)  # doctest:+SKIP
+            0    2012-01-07
+            1    2023-03-10
+            dtype: date32[day][pyarrow]
+
+            >>> func(s_pl)  # doctest: +NORMALIZE_WHITESPACE
+            shape: (2,)
+            Series: '' [date]
+            [
+               2012-01-07
+               2023-03-10
+            ]
+        """
+        return self._narwhals_series._from_compliant_series(
+            self._narwhals_series._compliant_series.dt.date()
+        )
+
     def year(self) -> Series:
         """
         Get the year in a datetime series.
diff --git a/pyproject.toml b/pyproject.toml
index a1ed619f..acfa8dea 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "narwhals"
-version = "1.1.9"
+version = "1.2.0"
 authors = [
   { name="Marco Gorelli", email="33491632+MarcoGorelli@users.noreply.github.com" },
 ]


Test patch:
diff --git a/tests/expr_and_series/arithmetic_test.py b/tests/expr_and_series/arithmetic_test.py
index 0e93e7c4..fbddb6f1 100644
--- a/tests/expr_and_series/arithmetic_test.py
+++ b/tests/expr_and_series/arithmetic_test.py
@@ -28,12 +28,6 @@ def test_arithmetic(
     constructor: Any,
     request: Any,
 ) -> None:
-    if "dask" in str(constructor) and attr not in [
-        "__add__",
-        "__sub__",
-        "__mul__",
-    ]:
-        request.applymarker(pytest.mark.xfail)
     if attr == "__mod__" and any(
         x in str(constructor) for x in ["pandas_pyarrow", "pyarrow_table", "modin"]
     ):
@@ -64,8 +58,6 @@ def test_right_arithmetic(
     constructor: Any,
     request: Any,
 ) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
     if attr == "__rmod__" and any(
         x in str(constructor) for x in ["pandas_pyarrow", "pyarrow_table", "modin"]
     ):
diff --git a/tests/expr_and_series/clip_test.py b/tests/expr_and_series/clip_test.py
index 94b54272..909b153b 100644
--- a/tests/expr_and_series/clip_test.py
+++ b/tests/expr_and_series/clip_test.py
@@ -1,24 +1,35 @@
 from typing import Any
 
-import pytest
-
 import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 
-def test_clip(request: Any, constructor: Any) -> None:
-    if "pyarrow_table" in str(constructor) or "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_clip(constructor: Any) -> None:
     df = nw.from_native(constructor({"a": [1, 2, 3, -4, 5]}))
-    result = df.select(b=nw.col("a").clip(3, 5))
-    expected = {"b": [3, 3, 3, 3, 5]}
+    result = df.select(
+        lower_only=nw.col("a").clip(lower_bound=3),
+        upper_only=nw.col("a").clip(upper_bound=4),
+        both=nw.col("a").clip(3, 4),
+    )
+    expected = {
+        "lower_only": [3, 3, 3, 3, 5],
+        "upper_only": [1, 2, 3, -4, 4],
+        "both": [3, 3, 3, 3, 4],
+    }
     compare_dicts(result, expected)
 
 
-def test_clip_series(request: Any, constructor_eager: Any) -> None:
-    if "pyarrow_table" in str(constructor_eager):
-        request.applymarker(pytest.mark.xfail)
+def test_clip_series(constructor_eager: Any) -> None:
     df = nw.from_native(constructor_eager({"a": [1, 2, 3, -4, 5]}), eager_only=True)
-    result = {"b": df["a"].clip(3, 5)}
-    expected = {"b": [3, 3, 3, 3, 5]}
+    result = {
+        "lower_only": df["a"].clip(lower_bound=3),
+        "upper_only": df["a"].clip(upper_bound=4),
+        "both": df["a"].clip(3, 4),
+    }
+
+    expected = {
+        "lower_only": [3, 3, 3, 3, 5],
+        "upper_only": [1, 2, 3, -4, 4],
+        "both": [3, 3, 3, 3, 4],
+    }
     compare_dicts(result, expected)
diff --git a/tests/expr_and_series/dt/datetime_attributes_test.py b/tests/expr_and_series/dt/datetime_attributes_test.py
index 44d80915..4d59567d 100644
--- a/tests/expr_and_series/dt/datetime_attributes_test.py
+++ b/tests/expr_and_series/dt/datetime_attributes_test.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+from datetime import date
 from datetime import datetime
 from typing import Any
 
@@ -19,6 +20,7 @@ data = {
 @pytest.mark.parametrize(
     ("attribute", "expected"),
     [
+        ("date", [date(2021, 3, 1), date(2020, 1, 2)]),
         ("year", [2021, 2020]),
         ("month", [3, 1]),
         ("day", [1, 2]),
@@ -32,8 +34,15 @@ data = {
     ],
 )
 def test_datetime_attributes(
-    constructor: Any, attribute: str, expected: list[int]
+    request: Any, constructor: Any, attribute: str, expected: list[int]
 ) -> None:
+    if (
+        attribute == "date"
+        and "pandas" in str(constructor)
+        and "pyarrow" not in str(constructor)
+    ):
+        request.applymarker(pytest.mark.xfail)
+
     df = nw.from_native(constructor(data))
     result = df.select(getattr(nw.col("a").dt, attribute)())
     compare_dicts(result, {"a": expected})
@@ -42,6 +51,7 @@ def test_datetime_attributes(
 @pytest.mark.parametrize(
     ("attribute", "expected"),
     [
+        ("date", [date(2021, 3, 1), date(2020, 1, 2)]),
         ("year", [2021, 2020]),
         ("month", [3, 1]),
         ("day", [1, 2]),
@@ -55,8 +65,27 @@ def test_datetime_attributes(
     ],
 )
 def test_datetime_attributes_series(
-    constructor_eager: Any, attribute: str, expected: list[int]
+    request: Any, constructor_eager: Any, attribute: str, expected: list[int]
 ) -> None:
+    if (
+        attribute == "date"
+        and "pandas" in str(constructor_eager)
+        and "pyarrow" not in str(constructor_eager)
+    ):
+        request.applymarker(pytest.mark.xfail)
+
     df = nw.from_native(constructor_eager(data), eager_only=True)
     result = df.select(getattr(df["a"].dt, attribute)())
     compare_dicts(result, {"a": expected})
+
+
+def test_datetime_chained_attributes(request: Any, constructor_eager: Any) -> None:
+    if "pandas" in str(constructor_eager) and "pyarrow" not in str(constructor_eager):
+        request.applymarker(pytest.mark.xfail)
+
+    df = nw.from_native(constructor_eager(data), eager_only=True)
+    result = df.select(df["a"].dt.date().dt.year())
+    compare_dicts(result, {"a": [2021, 2020]})
+
+    result = df.select(nw.col("a").dt.date().dt.year())
+    compare_dicts(result, {"a": [2021, 2020]})
diff --git a/tests/expr_and_series/name/keep_test.py b/tests/expr_and_series/name/keep_test.py
index ea937eaf..beceb7b2 100644
--- a/tests/expr_and_series/name/keep_test.py
+++ b/tests/expr_and_series/name/keep_test.py
@@ -12,27 +12,21 @@ from tests.utils import compare_dicts
 data = {"foo": [1, 2, 3], "BAR": [4, 5, 6]}
 
 
-def test_keep(constructor: Any, request: Any) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_keep(constructor: Any) -> None:
     df = nw.from_native(constructor(data))
     result = df.select((nw.col("foo", "BAR") * 2).name.keep())
     expected = {k: [e * 2 for e in v] for k, v in data.items()}
     compare_dicts(result, expected)
 
 
-def test_keep_after_alias(constructor: Any, request: Any) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_keep_after_alias(constructor: Any) -> None:
     df = nw.from_native(constructor(data))
     result = df.select((nw.col("foo")).alias("alias_for_foo").name.keep())
     expected = {"foo": data["foo"]}
     compare_dicts(result, expected)
 
 
-def test_keep_raise_anonymous(constructor: Any, request: Any) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_keep_raise_anonymous(constructor: Any) -> None:
     df_raw = constructor(data)
     df = nw.from_native(df_raw)
 
diff --git a/tests/expr_and_series/name/map_test.py b/tests/expr_and_series/name/map_test.py
index 1624a484..9aaf284b 100644
--- a/tests/expr_and_series/name/map_test.py
+++ b/tests/expr_and_series/name/map_test.py
@@ -16,27 +16,21 @@ def map_func(s: str | None) -> str:
     return str(s)[::-1].lower()
 
 
-def test_map(constructor: Any, request: Any) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_map(constructor: Any) -> None:
     df = nw.from_native(constructor(data))
     result = df.select((nw.col("foo", "BAR") * 2).name.map(function=map_func))
     expected = {map_func(k): [e * 2 for e in v] for k, v in data.items()}
     compare_dicts(result, expected)
 
 
-def test_map_after_alias(constructor: Any, request: Any) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_map_after_alias(constructor: Any) -> None:
     df = nw.from_native(constructor(data))
     result = df.select((nw.col("foo")).alias("alias_for_foo").name.map(function=map_func))
     expected = {map_func("foo"): data["foo"]}
     compare_dicts(result, expected)
 
 
-def test_map_raise_anonymous(constructor: Any, request: Any) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_map_raise_anonymous(constructor: Any) -> None:
     df_raw = constructor(data)
     df = nw.from_native(df_raw)
 
diff --git a/tests/expr_and_series/name/prefix_test.py b/tests/expr_and_series/name/prefix_test.py
index 59d35232..1c904660 100644
--- a/tests/expr_and_series/name/prefix_test.py
+++ b/tests/expr_and_series/name/prefix_test.py
@@ -13,27 +13,21 @@ data = {"foo": [1, 2, 3], "BAR": [4, 5, 6]}
 prefix = "with_prefix_"
 
 
-def test_prefix(constructor: Any, request: Any) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_prefix(constructor: Any) -> None:
     df = nw.from_native(constructor(data))
     result = df.select((nw.col("foo", "BAR") * 2).name.prefix(prefix))
     expected = {prefix + str(k): [e * 2 for e in v] for k, v in data.items()}
     compare_dicts(result, expected)
 
 
-def test_suffix_after_alias(constructor: Any, request: Any) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_suffix_after_alias(constructor: Any) -> None:
     df = nw.from_native(constructor(data))
     result = df.select((nw.col("foo")).alias("alias_for_foo").name.prefix(prefix))
     expected = {prefix + "foo": data["foo"]}
     compare_dicts(result, expected)
 
 
-def test_prefix_raise_anonymous(constructor: Any, request: Any) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_prefix_raise_anonymous(constructor: Any) -> None:
     df_raw = constructor(data)
     df = nw.from_native(df_raw)
 
diff --git a/tests/expr_and_series/name/suffix_test.py b/tests/expr_and_series/name/suffix_test.py
index ccceaefd..f1432647 100644
--- a/tests/expr_and_series/name/suffix_test.py
+++ b/tests/expr_and_series/name/suffix_test.py
@@ -13,27 +13,21 @@ data = {"foo": [1, 2, 3], "BAR": [4, 5, 6]}
 suffix = "_with_suffix"
 
 
-def test_suffix(constructor: Any, request: Any) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_suffix(constructor: Any) -> None:
     df = nw.from_native(constructor(data))
     result = df.select((nw.col("foo", "BAR") * 2).name.suffix(suffix))
     expected = {str(k) + suffix: [e * 2 for e in v] for k, v in data.items()}
     compare_dicts(result, expected)
 
 
-def test_suffix_after_alias(constructor: Any, request: Any) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_suffix_after_alias(constructor: Any) -> None:
     df = nw.from_native(constructor(data))
     result = df.select((nw.col("foo")).alias("alias_for_foo").name.suffix(suffix))
     expected = {"foo" + suffix: data["foo"]}
     compare_dicts(result, expected)
 
 
-def test_suffix_raise_anonymous(constructor: Any, request: Any) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_suffix_raise_anonymous(constructor: Any) -> None:
     df_raw = constructor(data)
     df = nw.from_native(df_raw)
 
diff --git a/tests/expr_and_series/name/to_lowercase_test.py b/tests/expr_and_series/name/to_lowercase_test.py
index 9575bcb0..3aa569f1 100644
--- a/tests/expr_and_series/name/to_lowercase_test.py
+++ b/tests/expr_and_series/name/to_lowercase_test.py
@@ -12,27 +12,21 @@ from tests.utils import compare_dicts
 data = {"foo": [1, 2, 3], "BAR": [4, 5, 6]}
 
 
-def test_to_lowercase(constructor: Any, request: Any) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_to_lowercase(constructor: Any) -> None:
     df = nw.from_native(constructor(data))
     result = df.select((nw.col("foo", "BAR") * 2).name.to_lowercase())
     expected = {k.lower(): [e * 2 for e in v] for k, v in data.items()}
     compare_dicts(result, expected)
 
 
-def test_to_lowercase_after_alias(constructor: Any, request: Any) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_to_lowercase_after_alias(constructor: Any) -> None:
     df = nw.from_native(constructor(data))
     result = df.select((nw.col("BAR")).alias("ALIAS_FOR_BAR").name.to_lowercase())
     expected = {"bar": data["BAR"]}
     compare_dicts(result, expected)
 
 
-def test_to_lowercase_raise_anonymous(constructor: Any, request: Any) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_to_lowercase_raise_anonymous(constructor: Any) -> None:
     df_raw = constructor(data)
     df = nw.from_native(df_raw)
 
diff --git a/tests/expr_and_series/name/to_uppercase_test.py b/tests/expr_and_series/name/to_uppercase_test.py
index 10f493f9..df3e89ee 100644
--- a/tests/expr_and_series/name/to_uppercase_test.py
+++ b/tests/expr_and_series/name/to_uppercase_test.py
@@ -12,27 +12,21 @@ from tests.utils import compare_dicts
 data = {"foo": [1, 2, 3], "BAR": [4, 5, 6]}
 
 
-def test_to_uppercase(constructor: Any, request: Any) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_to_uppercase(constructor: Any) -> None:
     df = nw.from_native(constructor(data))
     result = df.select((nw.col("foo", "BAR") * 2).name.to_uppercase())
     expected = {k.upper(): [e * 2 for e in v] for k, v in data.items()}
     compare_dicts(result, expected)
 
 
-def test_to_uppercase_after_alias(constructor: Any, request: Any) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_to_uppercase_after_alias(constructor: Any) -> None:
     df = nw.from_native(constructor(data))
     result = df.select((nw.col("foo")).alias("alias_for_foo").name.to_uppercase())
     expected = {"FOO": data["foo"]}
     compare_dicts(result, expected)
 
 
-def test_to_uppercase_raise_anonymous(constructor: Any, request: Any) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_to_uppercase_raise_anonymous(constructor: Any) -> None:
     df_raw = constructor(data)
     df = nw.from_native(df_raw)
 
diff --git a/tests/expr_and_series/std_test.py b/tests/expr_and_series/std_test.py
index 341971c6..0c7e2f33 100644
--- a/tests/expr_and_series/std_test.py
+++ b/tests/expr_and_series/std_test.py
@@ -1,16 +1,12 @@
 from typing import Any
 
-import pytest
-
 import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
 
 
-def test_std(constructor: Any, request: Any) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_std(constructor: Any) -> None:
     df = nw.from_native(constructor(data))
     result = df.select(
         nw.col("a").std().alias("a_ddof_default"),
diff --git a/tests/frame/add_test.py b/tests/frame/add_test.py
index 20927f37..b885bb05 100644
--- a/tests/frame/add_test.py
+++ b/tests/frame/add_test.py
@@ -1,14 +1,10 @@
 from typing import Any
 
-import pytest
-
 import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 
-def test_add(constructor: Any, request: Any) -> None:
-    if "dask" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_add(constructor: Any) -> None:
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
     df = nw.from_native(constructor(data))
     result = df.with_columns(
diff --git a/tests/translate/from_native_test.py b/tests/translate/from_native_test.py
index d6ee5473..af49c022 100644
--- a/tests/translate/from_native_test.py
+++ b/tests/translate/from_native_test.py
@@ -191,7 +191,7 @@ def test_series_only_dask() -> None:
 )
 def test_eager_only_lazy_dask(eager_only: Any, context: Any) -> None:
     pytest.importorskip("dask")
-    pytest.importorskip("dask_expr")
+    pytest.importorskip("dask_expr", exc_type=ImportError)
     import dask.dataframe as dd
 
     dframe = dd.from_pandas(df_pd)