Repository: narwhals-dev/narwhals
Pull request #2465 (2025-05-01 14:44:35)

feat: add `dt.truncate`
<!--
# 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
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues

- Related issue #2438
- Closes #\<issue number\>

## Checklist

- [x] Code follows style guide (ruff)
- [x] Tests added
- [x] Documented the changes

## Comments

- The intervals larger than "d" are not implemented in pandas/dask. It seems that `dt.to_period()` can be used for them but it seems to be buggy so I didn't use it. Maybe there is a better way.
- Unit of 'w' is not implemented as there seems to be differences in how different backends calculate them.
- sqlframe seems to have some issue with adding a timezone, reported this in https://github.com/eakmanrq/sqlframe/issues/383 so hopefully after it is fixed, we can just remove the xfail.
- There seems to be different origin dates used by different backends so there is a condition on multiples used for 'mo', 'q', and 'y' (investigated by @MarcoGorelli ).


dangotbanned: This *might* be handy if you're interested in how the sausage was made 😉 

- https://github.com/pola-rs/polars/blob/ad1c7194d3f6b8be792924d76b3616e418aa4159/crates/polars-time/src/windows/duration.rs
MarcoGorelli: Investigating the test failures led to:

- https://github.com/apache/arrow/issues/46301
- https://github.com/pola-rs/polars/issues/22590

I think some API decision needs to be made on Polars' side, which we can choose to follow (or not)

For now, the simplest thing might be to just disallow `multiplier` being different from:
- `1`, `2`, `3`, `4`, `6`, `12` for 'month'
- `1`, `2`, `4` for 'quarter'
- `1` for 'year'

because then at least the results match independent of origin

Based on what the responses are from upstream libraries we can then choose what to do for other multipliers. I'd imagine that the ones listed here are by far the most common ones anyway
raisadz: > Investigating the test failures led to:
> 
> * [Incorrect result for `floor_temporal` with 3 and 'year' apache/arrow#46301](https://github.com/apache/arrow/issues/46301)
> * [bug: inconsistent origin used in `dt.truncate` pola-rs/polars#22590](https://github.com/pola-rs/polars/issues/22590)
> 
> I think some API decision needs to be made on Polars' side, which we can choose to follow (or not)
> 
> For now, the simplest thing might be to just disallow `multiplier` being different from:
> 
> * `1`, `2`, `3`, `4`, `6`, `12` for 'month'
> * `1`, `2`, `4` for 'quarter'
> * `1` for 'year'
> 
> because then at least the results match independent of origin
> 
> Based on what the responses are from upstream libraries we can then choose what to do for other multipliers. I'd imagine that the ones listed here are by far the most common ones anyway

Thank you for investigating this, @MarcoGorelli ! I added validation for multiples of 'mo', 'q', and 'y' units in [616b01f](https://github.com/narwhals-dev/narwhals/pull/2465/commits/616b01f64d0fb598ddd01901b28a7bf007828a2d)

Patch:
diff --git a/docs/api-reference/expr_dt.md b/docs/api-reference/expr_dt.md
index 15cbe5ef..31c8f604 100644
--- a/docs/api-reference/expr_dt.md
+++ b/docs/api-reference/expr_dt.md
@@ -23,6 +23,7 @@
         - total_nanoseconds
         - total_seconds
         - to_string
+        - truncate
         - weekday
         - year
       show_source: false
diff --git a/docs/api-reference/series_dt.md b/docs/api-reference/series_dt.md
index 6d93e2ea..6592445a 100644
--- a/docs/api-reference/series_dt.md
+++ b/docs/api-reference/series_dt.md
@@ -23,6 +23,7 @@
         - total_nanoseconds
         - total_seconds
         - to_string
+        - truncate
         - weekday
         - year
       show_source: false
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index 553dda1e..cbb4d351 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -108,7 +108,7 @@ class ArrowDataFrame(
         elif backend_version >= (14,) or isinstance(data, Collection):
             native = pa.table(data)
         elif supports_arrow_c_stream(data):  # pragma: no cover
-            msg = f"PyArrow>=14.0.0 is required for `from_arrow` for object of type {type(data).__name__!r}."
+            msg = f"'pyarrow>=14.0.0' is required for `from_arrow` for object of type {type(data).__name__!r}."
             raise ModuleNotFoundError(msg)
         else:  # pragma: no cover
             msg = f"`from_arrow` is not supported for object of type {type(data).__name__!r}."
diff --git a/narwhals/_arrow/series_dt.py b/narwhals/_arrow/series_dt.py
index 4d0e3266..8f273327 100644
--- a/narwhals/_arrow/series_dt.py
+++ b/narwhals/_arrow/series_dt.py
@@ -7,9 +7,11 @@ from typing import cast
 import pyarrow as pa
 import pyarrow.compute as pc
 
+from narwhals._arrow.utils import UNITS_DICT
 from narwhals._arrow.utils import ArrowSeriesNamespace
 from narwhals._arrow.utils import floordiv_compat
 from narwhals._arrow.utils import lit
+from narwhals._duration import parse_interval_string
 
 if TYPE_CHECKING:
     from narwhals._arrow.series import ArrowSeries
@@ -189,3 +191,9 @@ class ArrowSeriesDateTimeNamespace(ArrowSeriesNamespace):
         }
         factor = lit(unit_to_nano_factor[self.unit], type=pa.int64())
         return self.with_native(pc.multiply(self.native, factor).cast(pa.int64()))
+
+    def truncate(self, every: str) -> ArrowSeries:
+        multiple, unit = parse_interval_string(every)
+        return self.with_native(
+            pc.floor_temporal(self.native, multiple=multiple, unit=UNITS_DICT[unit])
+        )
diff --git a/narwhals/_arrow/typing.py b/narwhals/_arrow/typing.py
index f8cc173b..659432cf 100644
--- a/narwhals/_arrow/typing.py
+++ b/narwhals/_arrow/typing.py
@@ -33,6 +33,19 @@ if TYPE_CHECKING:
     IntoArrowExpr: TypeAlias = "ArrowExpr | ArrowSeries"
     TieBreaker: TypeAlias = Literal["min", "max", "first", "dense"]
     NullPlacement: TypeAlias = Literal["at_start", "at_end"]
+    NativeIntervalUnit: TypeAlias = Literal[
+        "year",
+        "quarter",
+        "month",
+        "week",
+        "day",
+        "hour",
+        "minute",
+        "second",
+        "millisecond",
+        "microsecond",
+        "nanosecond",
+    ]
 
     ChunkedArrayAny: TypeAlias = pa.ChunkedArray[Any]
     ArrayAny: TypeAlias = pa.Array[Any]
diff --git a/narwhals/_arrow/utils.py b/narwhals/_arrow/utils.py
index d1bf957f..2775700b 100644
--- a/narwhals/_arrow/utils.py
+++ b/narwhals/_arrow/utils.py
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING
 from typing import Any
 from typing import Iterable
 from typing import Iterator
+from typing import Mapping
 from typing import Sequence
 from typing import cast
 
@@ -25,7 +26,9 @@ if TYPE_CHECKING:
     from narwhals._arrow.typing import ArrayOrScalarT1
     from narwhals._arrow.typing import ArrayOrScalarT2
     from narwhals._arrow.typing import ChunkedArrayAny
+    from narwhals._arrow.typing import NativeIntervalUnit
     from narwhals._arrow.typing import ScalarAny
+    from narwhals._duration import IntervalUnit
     from narwhals.dtypes import DType
     from narwhals.typing import PythonLiteral
     from narwhals.utils import Version
@@ -59,6 +62,19 @@ else:
     from pyarrow.types import is_list
     from pyarrow.types import is_timestamp
 
+UNITS_DICT: Mapping[IntervalUnit, NativeIntervalUnit] = {
+    "y": "year",
+    "q": "quarter",
+    "mo": "month",
+    "d": "day",
+    "h": "hour",
+    "m": "minute",
+    "s": "second",
+    "ms": "millisecond",
+    "us": "microsecond",
+    "ns": "nanosecond",
+}
+
 lit = pa.scalar
 """Alias for `pyarrow.scalar`."""
 
diff --git a/narwhals/_compliant/expr.py b/narwhals/_compliant/expr.py
index 183060c8..96fe1b97 100644
--- a/narwhals/_compliant/expr.py
+++ b/narwhals/_compliant/expr.py
@@ -1005,6 +1005,9 @@ class EagerExprDateTimeNamespace(
     def total_nanoseconds(self) -> EagerExprT:
         return self.compliant._reuse_series_namespace("dt", "total_nanoseconds")
 
+    def truncate(self, every: str) -> EagerExprT:
+        return self.compliant._reuse_series_namespace("dt", "truncate", every=every)
+
 
 class EagerExprListNamespace(
     EagerExprNamespace[EagerExprT], ListNamespace[EagerExprT], Generic[EagerExprT]
diff --git a/narwhals/_dask/expr_dt.py b/narwhals/_dask/expr_dt.py
index bf949871..5d1386da 100644
--- a/narwhals/_dask/expr_dt.py
+++ b/narwhals/_dask/expr_dt.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING
 
+from narwhals._duration import parse_interval_string
 from narwhals._pandas_like.utils import calculate_timestamp_date
 from narwhals._pandas_like.utils import calculate_timestamp_datetime
 from narwhals._pandas_like.utils import native_to_narwhals_dtype
@@ -157,3 +158,15 @@ class DaskExprDateTimeNamespace:
             lambda _input: _input.dt.total_seconds() * 1_000_000_000 // 1,
             "total_nanoseconds",
         )
+
+    def truncate(self, every: str) -> DaskExpr:
+        _, unit = parse_interval_string(every)
+        if unit in {"mo", "q", "y"}:
+            msg = f"Truncating to {unit} is not supported yet for dask."
+            raise NotImplementedError(msg)
+        if unit == "m":
+            every = every.replace("m", "min", 1)
+        return self._compliant_expr._with_callable(
+            lambda _input: _input.dt.floor(every),
+            "truncate",
+        )
diff --git a/narwhals/_duckdb/dataframe.py b/narwhals/_duckdb/dataframe.py
index e08757fd..c1e34d26 100644
--- a/narwhals/_duckdb/dataframe.py
+++ b/narwhals/_duckdb/dataframe.py
@@ -247,7 +247,7 @@ class DuckDBLazyFrame(
         if parse_version(pd) >= (1, 0, 0):
             return self.native.df()
         else:  # pragma: no cover
-            msg = f"Conversion to pandas requires pandas>=1.0.0, found {pd.__version__}"
+            msg = f"Conversion to pandas requires 'pandas>=1.0.0', found {pd.__version__}"
             raise NotImplementedError(msg)
 
     def to_arrow(self) -> pa.Table:
@@ -292,7 +292,7 @@ class DuckDBLazyFrame(
 
         if native_how == "cross":
             if self._backend_version < (1, 1, 4):
-                msg = f"DuckDB>=1.1.4 is required for cross-join, found version: {self._backend_version}"
+                msg = f"'duckdb>=1.1.4' is required for cross-join, found version: {self._backend_version}"
                 raise NotImplementedError(msg)
             rel = self.native.set_alias("lhs").cross(other.native.set_alias("rhs"))
         else:
diff --git a/narwhals/_duckdb/expr.py b/narwhals/_duckdb/expr.py
index a216a3fe..81723d52 100644
--- a/narwhals/_duckdb/expr.py
+++ b/narwhals/_duckdb/expr.py
@@ -678,7 +678,7 @@ class DuckDBExpr(LazyExpr["DuckDBLazyFrame", "duckdb.Expression"]):
     ) -> Self:
         if strategy is not None:
             if self._backend_version < (1, 3):  # pragma: no cover
-                msg = f"`fill_null` with `strategy={strategy}` is only available in DuckDB>=1.3.0."
+                msg = f"`fill_null` with `strategy={strategy}` is only available in 'duckdb>=1.3.0'."
                 raise NotImplementedError(msg)
 
             def _fill_with_strategy(window_inputs: WindowInputs) -> duckdb.Expression:
diff --git a/narwhals/_duckdb/expr_dt.py b/narwhals/_duckdb/expr_dt.py
index e2bc6c2e..d1fe45b7 100644
--- a/narwhals/_duckdb/expr_dt.py
+++ b/narwhals/_duckdb/expr_dt.py
@@ -1,10 +1,13 @@
 from __future__ import annotations
 
+from datetime import datetime
 from typing import TYPE_CHECKING
 
 from duckdb import FunctionExpression
 
+from narwhals._duckdb.utils import UNITS_DICT
 from narwhals._duckdb.utils import lit
+from narwhals._duration import parse_interval_string
 
 if TYPE_CHECKING:
     from narwhals._duckdb.expr import DuckDBExpr
@@ -108,3 +111,15 @@ class DuckDBExprDateTimeNamespace:
     def total_nanoseconds(self) -> DuckDBExpr:
         msg = "`total_nanoseconds` is not implemented for DuckDB"
         raise NotImplementedError(msg)
+
+    def truncate(self, every: str) -> DuckDBExpr:
+        multiple, unit = parse_interval_string(every)
+        if unit == "ns":
+            msg = "Truncating to nanoseconds is not yet supported for DuckDB."
+            raise NotImplementedError(msg)
+        every = f"{multiple!s} {UNITS_DICT[unit]}"
+        return self._compliant_expr._with_callable(
+            lambda _input: FunctionExpression(
+                "time_bucket", lit(every), _input, lit(datetime(1970, 1, 1))
+            )
+        )
diff --git a/narwhals/_duckdb/utils.py b/narwhals/_duckdb/utils.py
index 53382a03..f0de44d4 100644
--- a/narwhals/_duckdb/utils.py
+++ b/narwhals/_duckdb/utils.py
@@ -17,6 +17,18 @@ if TYPE_CHECKING:
     from narwhals._duckdb.expr import DuckDBExpr
     from narwhals.dtypes import DType
 
+UNITS_DICT = {
+    "y": "year",
+    "q": "quarter",
+    "mo": "month",
+    "d": "day",
+    "h": "hour",
+    "m": "minute",
+    "s": "second",
+    "ms": "millisecond",
+    "us": "microsecond",
+    "ns": "nanosecond",
+}
 
 col = duckdb.ColumnExpression
 """Alias for `duckdb.ColumnExpression`."""
diff --git a/narwhals/_duration.py b/narwhals/_duration.py
new file mode 100644
index 00000000..bdb91ac9
--- /dev/null
+++ b/narwhals/_duration.py
@@ -0,0 +1,63 @@
+"""Tools for working with the Polars duration string language."""
+
+from __future__ import annotations
+
+import re
+from typing import TYPE_CHECKING
+from typing import Literal
+from typing import cast
+from typing import get_args
+
+if TYPE_CHECKING:
+    from typing_extensions import TypeAlias
+
+__all__ = ["IntervalUnit", "parse_interval_string"]
+
+IntervalUnit: TypeAlias = Literal["ns", "us", "ms", "s", "m", "h", "d", "mo", "q", "y"]
+"""A Polars duration string interval unit.
+
+- 'ns': nanosecond.
+- 'us': microsecond.
+- 'ms': millisecond.
+- 's': second.
+- 'm': minute.
+- 'h': hour.
+- 'd': day.
+- 'mo': month.
+- 'q': quarter.
+- 'y': year.
+"""
+
+PATTERN_INTERVAL: re.Pattern[str] = re.compile(
+    r"^(?P<multiple>\d+)(?P<unit>ns|us|ms|mo|m|s|h|d|q|y)\Z"
+)
+MONTH_MULTIPLES = frozenset([1, 2, 3, 4, 6, 12])
+QUARTER_MULTIPLES = frozenset([1, 2, 4])
+
+
+def parse_interval_string(every: str) -> tuple[int, IntervalUnit]:
+    """Parse a string like "1d", "2h", "3m" into a tuple of (number, unit).
+
+    Returns:
+        A tuple of multiple and unit parsed from the interval string.
+    """
+    if match := PATTERN_INTERVAL.match(every):
+        multiple = int(match["multiple"])
+        unit = cast("IntervalUnit", match["unit"])
+        if unit == "mo" and multiple not in MONTH_MULTIPLES:
+            msg = f"Only the following multiples are supported for 'mo' unit: {MONTH_MULTIPLES}.\nGot: {multiple}."
+            raise ValueError(msg)
+        if unit == "q" and multiple not in QUARTER_MULTIPLES:
+            msg = f"Only the following multiples are supported for 'q' unit: {QUARTER_MULTIPLES}.\nGot: {multiple}."
+            raise ValueError(msg)
+        if unit == "y" and multiple != 1:
+            msg = (
+                f"Only multiple 1 is currently supported for 'y' unit.\nGot: {multiple}."
+            )
+            raise ValueError(msg)
+        return multiple, unit
+    msg = (
+        f"Invalid `every` string: {every}. Expected string of kind <number><unit>, "
+        f"where 'unit' is one of: {get_args(IntervalUnit)}."
+    )
+    raise ValueError(msg)
diff --git a/narwhals/_enum.py b/narwhals/_enum.py
new file mode 100644
index 00000000..929eecca
--- /dev/null
+++ b/narwhals/_enum.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+# ruff: noqa: ARG004
+from enum import Enum
+from typing import Any
+
+
+class NoAutoEnum(Enum):
+    """Enum base class that prohibits the use of enum.auto() for value assignment.
+
+    This behavior is achieved by overriding the value generation mechanism.
+
+    Examples:
+        >>> from enum import auto
+        >>> from narwhals._enum import NoAutoEnum
+        >>>
+        >>> class Colors(NoAutoEnum):
+        ...     RED = 1
+        ...     GREEN = 2
+        >>> Colors.RED
+        <Colors.RED: 1>
+
+        >>> class ColorsWithAuto(NoAutoEnum):
+        ...     RED = 1
+        ...     GREEN = auto()
+        Traceback (most recent call last):
+        ...
+        ValueError: Creating values with `auto()` is not allowed. Please provide a value manually instead.
+
+    Raises:
+        ValueError: If `auto()` is attempted to be used for any enum member value.
+    """
+
+    @staticmethod
+    def _generate_next_value_(
+        name: str, start: int, count: int, last_values: list[Any]
+    ) -> Any:
+        msg = "Creating values with `auto()` is not allowed. Please provide a value manually instead."
+        raise ValueError(msg)
+
+
+__all__ = ["NoAutoEnum"]
diff --git a/narwhals/_interchange/dataframe.py b/narwhals/_interchange/dataframe.py
index a96fba16..4e5581fd 100644
--- a/narwhals/_interchange/dataframe.py
+++ b/narwhals/_interchange/dataframe.py
@@ -119,7 +119,7 @@ class InterchangeFrame:
         else:  # pragma: no cover
             msg = (
                 "Conversion to pandas is achieved via interchange protocol which requires"
-                f" pandas>=1.5.0 to be installed, found {pd.__version__}"
+                f" 'pandas>=1.5.0' to be installed, found {pd.__version__}"
             )
             raise NotImplementedError(msg)
 
diff --git a/narwhals/_namespace.py b/narwhals/_namespace.py
index d1a5d20e..c5ac14bd 100644
--- a/narwhals/_namespace.py
+++ b/narwhals/_namespace.py
@@ -60,7 +60,7 @@ if TYPE_CHECKING:
     _Dask: TypeAlias = Literal["dask"]
     _DuckDB: TypeAlias = Literal["duckdb"]
     _PandasLike: TypeAlias = Literal["pandas", "cudf", "modin"]
-    _SparkLike: TypeAlias = Literal["pyspark", "sqlframe"]
+    _SparkLike: TypeAlias = Literal["pyspark", "sqlframe", "pyspark[connect]"]
     _EagerOnly: TypeAlias = "_PandasLike | _Arrow"
     _EagerAllowed: TypeAlias = "_Polars | _EagerOnly"
     _LazyOnly: TypeAlias = "_SparkLike | _Dask | _DuckDB"
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index cac4ea8c..c8aaae32 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -941,7 +941,7 @@ class PandasLikeDataFrame(
         if self._implementation is Implementation.PANDAS and (
             self._backend_version < (1, 1)
         ):  # pragma: no cover
-            msg = "pivot is only supported for pandas>=1.1"
+            msg = "pivot is only supported for 'pandas>=1.1'"
             raise NotImplementedError(msg)
         if self._implementation is Implementation.MODIN:
             msg = "pivot is not supported for Modin backend due to https://github.com/modin-project/modin/issues/7409."
diff --git a/narwhals/_pandas_like/series_dt.py b/narwhals/_pandas_like/series_dt.py
index 641368fc..efa9625c 100644
--- a/narwhals/_pandas_like/series_dt.py
+++ b/narwhals/_pandas_like/series_dt.py
@@ -4,9 +4,11 @@ from typing import TYPE_CHECKING
 from typing import Any
 
 from narwhals._compliant.any_namespace import DateTimeNamespace
+from narwhals._duration import parse_interval_string
 from narwhals._pandas_like.utils import PandasLikeSeriesNamespace
 from narwhals._pandas_like.utils import calculate_timestamp_date
 from narwhals._pandas_like.utils import calculate_timestamp_datetime
+from narwhals._pandas_like.utils import get_dtype_backend
 from narwhals._pandas_like.utils import int_dtype_mapper
 from narwhals._pandas_like.utils import is_pyarrow_dtype_backend
 
@@ -190,3 +192,39 @@ class PandasLikeSeriesDateTimeNamespace(
             raise TypeError(msg)
         result[mask_na] = None
         return self.with_native(result)
+
+    def truncate(self, every: str) -> PandasLikeSeries:
+        multiple, unit = parse_interval_string(every)
+        native = self.native
+        dtype_backend = get_dtype_backend(native.dtype, self.compliant._implementation)
+        if unit in {"mo", "q", "y"}:
+            if self.implementation.is_cudf():
+                msg = f"Truncating to {unit} is not supported yet for cuDF."
+                raise NotImplementedError(msg)
+            if dtype_backend == "pyarrow":
+                import pyarrow.compute as pc  # ignore-banned-import
+
+                from narwhals._arrow.utils import UNITS_DICT
+
+                ca = native.array._pa_array
+                result_arr = pc.floor_temporal(ca, multiple, UNITS_DICT[unit])
+            else:
+                if unit == "q":
+                    multiple *= 3
+                    np_unit = "M"
+                elif unit == "mo":
+                    np_unit = "M"
+                else:
+                    np_unit = "Y"
+                arr = native.values
+                arr_dtype = arr.dtype
+                result_arr = arr.astype(f"datetime64[{multiple}{np_unit}]").astype(
+                    arr_dtype
+                )
+            result_native = native.__class__(
+                result_arr, dtype=native.dtype, index=native.index, name=native.name
+            )
+            return self.with_native(result_native)
+        if unit == "m":
+            every = every.replace("m", "min", 1)
+        return self.with_native(self.native.dt.floor(every))
diff --git a/narwhals/_pandas_like/utils.py b/narwhals/_pandas_like/utils.py
index 9ad4efa2..19b14bb0 100644
--- a/narwhals/_pandas_like/utils.py
+++ b/narwhals/_pandas_like/utils.py
@@ -507,7 +507,7 @@ def narwhals_to_native_dtype(  # noqa: PLR0915
         try:
             import pyarrow as pa  # ignore-banned-import
         except ModuleNotFoundError:  # pragma: no cover
-            msg = "PyArrow>=11.0.0 is required for `Date` dtype."
+            msg = "'pyarrow>=11.0.0' is required for `Date` dtype."
         return "date32[pyarrow]"
     if isinstance_or_issubclass(dtype, dtypes.Enum):
         if version is Version.V1:
diff --git a/narwhals/_polars/dataframe.py b/narwhals/_polars/dataframe.py
index 4dcd0be6..a25f072b 100644
--- a/narwhals/_polars/dataframe.py
+++ b/narwhals/_polars/dataframe.py
@@ -276,7 +276,7 @@ class PolarsDataFrame:
         self, dtype: Any | None = None, *, copy: bool | None = None
     ) -> _2DArray:
         if self._backend_version < (0, 20, 28) and copy is not None:
-            msg = "`copy` in `__array__` is only supported for Polars>=0.20.28"
+            msg = "`copy` in `__array__` is only supported for 'polars>=0.20.28'"
             raise NotImplementedError(msg)
         if self._backend_version < (0, 20, 28):
             return self.native.__array__(dtype)
diff --git a/narwhals/_polars/expr.py b/narwhals/_polars/expr.py
index c6e41205..c46aede6 100644
--- a/narwhals/_polars/expr.py
+++ b/narwhals/_polars/expr.py
@@ -9,6 +9,7 @@ from typing import Sequence
 
 import polars as pl
 
+from narwhals._duration import parse_interval_string
 from narwhals._polars.utils import extract_args_kwargs
 from narwhals._polars.utils import extract_native
 from narwhals._polars.utils import narwhals_to_native_dtype
@@ -314,6 +315,12 @@ class PolarsExprDateTimeNamespace:
     def __init__(self, expr: PolarsExpr) -> None:
         self._compliant_expr = expr
 
+    def truncate(self, every: str) -> PolarsExpr:
+        parse_interval_string(every)  # Ensure consistent error message is raised.
+        return self._compliant_expr._with_native(
+            self._compliant_expr.native.dt.truncate(every)
+        )
+
     def __getattr__(self, attr: str) -> Callable[[Any], PolarsExpr]:
         def func(*args: Any, **kwargs: Any) -> PolarsExpr:
             pos, kwds = extract_args_kwargs(args, kwargs)
diff --git a/narwhals/_spark_like/expr_dt.py b/narwhals/_spark_like/expr_dt.py
index 0e7766d4..b42c4757 100644
--- a/narwhals/_spark_like/expr_dt.py
+++ b/narwhals/_spark_like/expr_dt.py
@@ -2,6 +2,9 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING
 
+from narwhals._duration import parse_interval_string
+from narwhals._spark_like.utils import UNITS_DICT
+
 if TYPE_CHECKING:
     from sqlframe.base.column import Column
 
@@ -62,3 +65,15 @@ class SparkLikeExprDateTimeNamespace:
             return (self._compliant_expr._F.dayofweek(_input) + 6) % 7
 
         return self._compliant_expr._with_callable(_weekday)
+
+    def truncate(self, every: str) -> SparkLikeExpr:
+        multiple, unit = parse_interval_string(every)
+        if multiple != 1:
+            msg = f"Only multiple 1 is currently supported for Spark-like.\nGot {multiple!s}."
+            raise ValueError(msg)
+        format = UNITS_DICT[unit]
+
+        def _truncate(_input: Column) -> Column:
+            return self._compliant_expr._F.date_trunc(format, _input)
+
+        return self._compliant_expr._with_callable(_truncate)
diff --git a/narwhals/_spark_like/utils.py b/narwhals/_spark_like/utils.py
index 6c6ceacf..44c8c14f 100644
--- a/narwhals/_spark_like/utils.py
+++ b/narwhals/_spark_like/utils.py
@@ -24,6 +24,19 @@ if TYPE_CHECKING:
 
     _NativeDType: TypeAlias = sqlframe_types.DataType
 
+UNITS_DICT = {
+    "y": "year",
+    "q": "quarter",
+    "mo": "month",
+    "d": "day",
+    "h": "hour",
+    "m": "minute",
+    "s": "second",
+    "ms": "millisecond",
+    "us": "microsecond",
+    "ns": "nanosecond",
+}
+
 
 class WindowInputs:
     __slots__ = ("expr", "order_by", "partition_by")
diff --git a/narwhals/_translate.py b/narwhals/_translate.py
index 494b58c9..ea19e140 100644
--- a/narwhals/_translate.py
+++ b/narwhals/_translate.py
@@ -68,21 +68,6 @@ from typing import Iterable
 from typing import Mapping
 from typing import Protocol
 
-_MIN_TYPING_EXTENSIONS = 4, 4, 0
-_TYPING_EXTENSIONS = "typing_extensions"
-
-
-def _typing_extensions_has_pep_696() -> bool:  # pragma: no cover
-    from importlib.metadata import version
-    from importlib.util import find_spec
-
-    from narwhals.utils import parse_version
-
-    if find_spec(_TYPING_EXTENSIONS):
-        return parse_version(version(_TYPING_EXTENSIONS)) >= _MIN_TYPING_EXTENSIONS
-    return False
-
-
 if TYPE_CHECKING:
     import pyarrow as pa
     from typing_extensions import Self
@@ -96,8 +81,6 @@ else:  # pragma: no cover
 
     if sys.version_info >= (3, 13):
         from typing import TypeVar
-    elif _typing_extensions_has_pep_696():
-        from typing_extensions import TypeVar
     else:
         from typing import TypeVar as _TypeVar
 
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index 534a1ff4..adcdcc1d 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -479,7 +479,7 @@ class DataFrame(BaseFrame[DataFrameT]):
             >>> df_native = pd.DataFrame({"a": [1, 2, 3]})
             >>> df = nw.from_native(df_native)
             >>> df.implementation
-            <Implementation.PANDAS: 1>
+            <Implementation.PANDAS: 'pandas'>
             >>> df.implementation.is_pandas()
             True
             >>> df.implementation.is_pandas_like()
@@ -513,10 +513,10 @@ class DataFrame(BaseFrame[DataFrameT]):
         try:
             import pyarrow as pa  # ignore-banned-import
         except ModuleNotFoundError as exc:  # pragma: no cover
-            msg = f"PyArrow>=14.0.0 is required for `DataFrame.__arrow_c_stream__` for object of type {type(native_frame)}"
+            msg = f"'pyarrow>=14.0.0' is required for `DataFrame.__arrow_c_stream__` for object of type {type(native_frame)}"
             raise ModuleNotFoundError(msg) from exc
         if parse_version(pa) < (14, 0):  # pragma: no cover
-            msg = f"PyArrow>=14.0.0 is required for `DataFrame.__arrow_c_stream__` for object of type {type(native_frame)}"
+            msg = f"'pyarrow>=14.0.0' is required for `DataFrame.__arrow_c_stream__` for object of type {type(native_frame)}"
             raise ModuleNotFoundError(msg) from None
         pa_table = self.to_arrow()
         return pa_table.__arrow_c_stream__(requested_schema=requested_schema)  # type: ignore[no-untyped-call]
@@ -2258,7 +2258,7 @@ class LazyFrame(BaseFrame[FrameT]):
             >>> import dask.dataframe as dd
             >>> lf_native = dd.from_dict({"a": [1, 2]}, npartitions=1)
             >>> nw.from_native(lf_native).implementation
-            <Implementation.DASK: 7>
+            <Implementation.DASK: 'dask'>
         """
         return self._compliant_frame._implementation
 
diff --git a/narwhals/expr_cat.py b/narwhals/expr_cat.py
index f827c4e0..fa0b7347 100644
--- a/narwhals/expr_cat.py
+++ b/narwhals/expr_cat.py
@@ -4,8 +4,6 @@ from typing import TYPE_CHECKING
 from typing import Generic
 from typing import TypeVar
 
-from narwhals._expression_parsing import ExprKind
-
 if TYPE_CHECKING:
     from narwhals.expr import Expr
 
@@ -41,7 +39,6 @@ class ExprCatNamespace(Generic[ExprT]):
             │ mango  │
             └────────┘
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).cat.get_categories(),
-            self._expr._metadata.with_kind(ExprKind.FILTRATION),
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).cat.get_categories()
         )
diff --git a/narwhals/expr_dt.py b/narwhals/expr_dt.py
index 55cfdd78..e5c98418 100644
--- a/narwhals/expr_dt.py
+++ b/narwhals/expr_dt.py
@@ -43,8 +43,8 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             │ 2027-12-13 │
             └────────────┘
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).dt.date(), self._expr._metadata
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).dt.date()
         )
 
     def year(self) -> ExprT:
@@ -72,8 +72,8 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             |1 2065-01-01  2065|
             └──────────────────┘
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).dt.year(), self._expr._metadata
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).dt.year()
         )
 
     def month(self) -> ExprT:
@@ -98,9 +98,8 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             a: [[1978-06-01 00:00:00.000000,2065-01-01 00:00:00.000000]]
             month: [[6,1]]
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).dt.month(),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).dt.month()
         )
 
     def day(self) -> ExprT:
@@ -125,8 +124,8 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             a: [[1978-06-01 00:00:00.000000,2065-01-01 00:00:00.000000]]
             day: [[1,1]]
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).dt.day(), self._expr._metadata
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).dt.day()
         )
 
     def hour(self) -> ExprT:
@@ -160,8 +159,8 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             |└─────────────────────┴──────┘|
             └──────────────────────────────┘
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).dt.hour(), self._expr._metadata
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).dt.hour()
         )
 
     def minute(self) -> ExprT:
@@ -185,9 +184,8 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             0 1978-01-01 01:01:00       1
             1 2065-01-01 10:20:00      20
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).dt.minute(),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).dt.minute()
         )
 
     def second(self) -> ExprT:
@@ -217,9 +215,8 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             a: [[1978-01-01 01:01:01.000000,2065-01-01 10:20:30.000000]]
             second: [[1,30]]
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).dt.second(),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).dt.second()
         )
 
     def millisecond(self) -> ExprT:
@@ -251,9 +248,8 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             a: [[1978-01-01 01:01:01.000000,2065-01-01 10:20:30.067000]]
             millisecond: [[0,67]]
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).dt.millisecond(),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).dt.millisecond()
         )
 
     def microsecond(self) -> ExprT:
@@ -285,9 +281,8 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             a: [[1978-01-01 01:01:01.000000,2065-01-01 10:20:30.067000]]
             microsecond: [[0,67000]]
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).dt.microsecond(),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).dt.microsecond()
         )
 
     def nanosecond(self) -> ExprT:
@@ -319,9 +314,8 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             a: [[1978-01-01 01:01:01.000000,2065-01-01 10:20:30.067000]]
             nanosecond: [[0,67000000]]
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).dt.nanosecond(),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).dt.nanosecond()
         )
 
     def ordinal_day(self) -> ExprT:
@@ -347,9 +341,8 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             |1 2020-08-03            216|
             └───────────────────────────┘
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).dt.ordinal_day(),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).dt.ordinal_day()
         )
 
     def weekday(self) -> ExprT:
@@ -375,9 +368,8 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             |1 2020-08-03           1|
             └────────────────────────┘
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).dt.weekday(),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).dt.weekday()
         )
 
     def total_minutes(self) -> ExprT:
@@ -412,9 +404,8 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             │ 20m 40s      ┆ 20              │
             └──────────────┴─────────────────┘
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).dt.total_minutes(),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).dt.total_minutes()
         )
 
     def total_seconds(self) -> ExprT:
@@ -449,9 +440,8 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             │ 20s 40ms     ┆ 20              │
             └──────────────┴─────────────────┘
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).dt.total_seconds(),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).dt.total_seconds()
         )
 
     def total_milliseconds(self) -> ExprT:
@@ -491,9 +481,8 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             │ 20040µs      ┆ 20                   │
             └──────────────┴──────────────────────┘
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).dt.total_milliseconds(),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).dt.total_milliseconds()
         )
 
     def total_microseconds(self) -> ExprT:
@@ -530,9 +519,8 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             a: [[10,1200]]
             a_total_microseconds: [[10,1200]]
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).dt.total_microseconds(),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).dt.total_microseconds()
         )
 
     def total_nanoseconds(self) -> ExprT:
@@ -568,9 +556,8 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             0 2024-01-01 00:00:00.000000001                       NaN
             1 2024-01-01 00:00:00.000000002                       1.0
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).dt.total_nanoseconds(),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).dt.total_nanoseconds()
         )
 
     def to_string(self, format: str) -> ExprT:
@@ -641,9 +628,8 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             |└─────────────────────┘|
             └───────────────────────┘
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).dt.to_string(format),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).dt.to_string(format)
         )
 
     def replace_time_zone(self, time_zone: str | None) -> ExprT:
@@ -673,11 +659,10 @@ class ExprDateTimeNamespace(Generic[ExprT]):
             0 2024-01-01 00:00:00+05:45
             1 2024-01-02 00:00:00+05:45
         """
-        return self._expr.__class__(
+        return self._expr._with_callable(
             lambda plx: self._expr._to_compliant_expr(plx).dt.replace_time_zone(
                 time_zone
             ),
-            self._expr._metadata,
         )
 
     def convert_time_zone(self, time_zone: str) -> ExprT:
@@ -713,11 +698,10 @@ class ExprDateTimeNamespace(Generic[ExprT]):
         if time_zone is None:
             msg = "Target `time_zone` cannot be `None` in `convert_time_zone`. Please use `replace_time_zone(None)` if you want to remove the time zone."
             raise TypeError(msg)
-        return self._expr.__class__(
+        return self._expr._with_callable(
             lambda plx: self._expr._to_compliant_expr(plx).dt.convert_time_zone(
                 time_zone
             ),
-            self._expr._metadata,
         )
 
     def timestamp(self, time_unit: TimeUnit = "us") -> ExprT:
@@ -757,7 +741,53 @@ class ExprDateTimeNamespace(Generic[ExprT]):
                 f"\n\nExpected one of {{'ns', 'us', 'ms'}}, got {time_unit!r}."
             )
             raise ValueError(msg)
-        return self._expr.__class__(
+        return self._expr._with_callable(
             lambda plx: self._expr._to_compliant_expr(plx).dt.timestamp(time_unit),
-            self._expr._metadata,
+        )
+
+    def truncate(self, every: str) -> ExprT:
+        """Divide the date/datetime range into buckets.
+
+        Arguments:
+            every: Length of bucket. Must be of form `<multiple><unit>`,
+                where `multiple` is a positive integer and `unit` is one of:
+
+                - 'ns': nanosecond.
+                - 'us': microsecond.
+                - 'ms': millisecond.
+                - 's': second.
+                - 'm': minute.
+                - 'h': hour.
+                - 'd': day.
+                - 'mo': month.
+                - 'q': quarter.
+                - 'y': year.
+
+        Returns:
+            Expression of data type `Date` or `Datetime`.
+
+        Examples:
+            >>> from datetime import datetime
+            >>> import polars as pl
+            >>> import narwhals as nw
+            >>> df_native = pl.DataFrame({"datetime": [datetime(2021, 3, 1, 12, 34)]})
+            >>> df = nw.from_native(df_native)
+            >>> df.with_columns(
+            ...     nw.col("datetime").dt.truncate("1h").alias("datetime_trunc")
+            ... )
+            ┌─────────────────────────────────────────────┐
+            |             Narwhals DataFrame              |
+            |---------------------------------------------|
+            |shape: (1, 2)                                |
+            |┌─────────────────────┬─────────────────────┐|
+            |│ datetime            ┆ datetime_trunc      │|
+            |│ ---                 ┆ ---                 │|
+            |│ datetime[μs]        ┆ datetime[μs]        │|
+            |╞═════════════════════╪═════════════════════╡|
+            |│ 2021-03-01 12:34:00 ┆ 2021-03-01 12:00:00 │|
+            |└─────────────────────┴─────────────────────┘|
+            └─────────────────────────────────────────────┘
+        """
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).dt.truncate(every),
         )
diff --git a/narwhals/expr_list.py b/narwhals/expr_list.py
index aad389ee..78cff354 100644
--- a/narwhals/expr_list.py
+++ b/narwhals/expr_list.py
@@ -44,7 +44,6 @@ class ExprListNamespace(Generic[ExprT]):
             |└──────────────┴───────┘|
             └────────────────────────┘
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).list.len(),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).list.len()
         )
diff --git a/narwhals/expr_name.py b/narwhals/expr_name.py
index 104f18b1..aa5670f5 100644
--- a/narwhals/expr_name.py
+++ b/narwhals/expr_name.py
@@ -34,9 +34,8 @@ class ExprNameNamespace(Generic[ExprT]):
             >>> df.select(nw.col("foo").alias("alias_for_foo").name.keep()).columns
             ['foo']
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).name.keep(),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).name.keep()
         )
 
     def map(self, function: Callable[[str], str]) -> ExprT:
@@ -62,9 +61,8 @@ class ExprNameNamespace(Generic[ExprT]):
             >>> df.select(nw.col("foo", "BAR").name.map(renaming_func)).columns
             ['oof', 'RAB']
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).name.map(function),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).name.map(function)
         )
 
     def prefix(self, prefix: str) -> ExprT:
@@ -89,9 +87,8 @@ class ExprNameNamespace(Generic[ExprT]):
             >>> df.select(nw.col("foo", "BAR").name.prefix("with_prefix")).columns
             ['with_prefixfoo', 'with_prefixBAR']
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).name.prefix(prefix),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).name.prefix(prefix)
         )
 
     def suffix(self, suffix: str) -> ExprT:
@@ -116,9 +113,8 @@ class ExprNameNamespace(Generic[ExprT]):
             >>> df.select(nw.col("foo", "BAR").name.suffix("_with_suffix")).columns
             ['foo_with_suffix', 'BAR_with_suffix']
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).name.suffix(suffix),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).name.suffix(suffix)
         )
 
     def to_lowercase(self) -> ExprT:
@@ -140,9 +136,8 @@ class ExprNameNamespace(Generic[ExprT]):
             >>> df.select(nw.col("foo", "BAR").name.to_lowercase()).columns
             ['foo', 'bar']
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).name.to_lowercase(),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).name.to_lowercase()
         )
 
     def to_uppercase(self) -> ExprT:
@@ -164,7 +159,6 @@ class ExprNameNamespace(Generic[ExprT]):
             >>> df.select(nw.col("foo", "BAR").name.to_uppercase()).columns
             ['FOO', 'BAR']
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).name.to_uppercase(),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).name.to_uppercase()
         )
diff --git a/narwhals/expr_str.py b/narwhals/expr_str.py
index 426ce755..87a38706 100644
--- a/narwhals/expr_str.py
+++ b/narwhals/expr_str.py
@@ -41,9 +41,8 @@ class ExprStringNamespace(Generic[ExprT]):
             |└───────┴───────────┘|
             └─────────────────────┘
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).str.len_chars(),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).str.len_chars()
         )
 
     def replace(
@@ -74,11 +73,10 @@ class ExprStringNamespace(Generic[ExprT]):
             |1  abc abc123   abc123|
             └──────────────────────┘
         """
-        return self._expr.__class__(
+        return self._expr._with_callable(
             lambda plx: self._expr._to_compliant_expr(plx).str.replace(
                 pattern, value, literal=literal, n=n
-            ),
-            self._expr._metadata,
+            )
         )
 
     def replace_all(self, pattern: str, value: str, *, literal: bool = False) -> ExprT:
@@ -106,11 +104,10 @@ class ExprStringNamespace(Generic[ExprT]):
             |1  abc abc123      123|
             └──────────────────────┘
         """
-        return self._expr.__class__(
+        return self._expr._with_callable(
             lambda plx: self._expr._to_compliant_expr(plx).str.replace_all(
                 pattern, value, literal=literal
-            ),
-            self._expr._metadata,
+            )
         )
 
     def strip_chars(self, characters: str | None = None) -> ExprT:
@@ -135,9 +132,8 @@ class ExprStringNamespace(Generic[ExprT]):
             ... )
             {'fruits': ['apple', '\nmango'], 'stripped': ['apple', 'mango']}
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).str.strip_chars(characters),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).str.strip_chars(characters)
         )
 
     def starts_with(self, prefix: str) -> ExprT:
@@ -164,9 +160,8 @@ class ExprStringNamespace(Generic[ExprT]):
             |2   None       None|
             └───────────────────┘
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).str.starts_with(prefix),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).str.starts_with(prefix)
         )
 
     def ends_with(self, suffix: str) -> ExprT:
@@ -193,9 +188,8 @@ class ExprStringNamespace(Generic[ExprT]):
             |2   None       None|
             └───────────────────┘
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).str.ends_with(suffix),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).str.ends_with(suffix)
         )
 
     def contains(self, pattern: str, *, literal: bool = False) -> ExprT:
@@ -227,11 +221,10 @@ class ExprStringNamespace(Generic[ExprT]):
             default_match: [[true,false,true]]
             case_insensitive_match: [[true,false,true]]
         """
-        return self._expr.__class__(
+        return self._expr._with_callable(
             lambda plx: self._expr._to_compliant_expr(plx).str.contains(
                 pattern, literal=literal
-            ),
-            self._expr._metadata,
+            )
         )
 
     def slice(self, offset: int, length: int | None = None) -> ExprT:
@@ -260,11 +253,10 @@ class ExprStringNamespace(Generic[ExprT]):
             |2  papaya       ya|
             └──────────────────┘
         """
-        return self._expr.__class__(
+        return self._expr._with_callable(
             lambda plx: self._expr._to_compliant_expr(plx).str.slice(
                 offset=offset, length=length
-            ),
-            self._expr._metadata,
+            )
         )
 
     def split(self, by: str) -> ExprT:
@@ -296,9 +288,8 @@ class ExprStringNamespace(Generic[ExprT]):
             |└─────────┴────────────────┘|
             └────────────────────────────┘
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).str.split(by=by),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).str.split(by=by)
         )
 
     def head(self, n: int = 5) -> ExprT:
@@ -326,9 +317,8 @@ class ExprStringNamespace(Generic[ExprT]):
             lyrics: [["taata","taatatata","zukkyun"]]
             lyrics_head: [["taata","taata","zukky"]]
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).str.slice(0, n),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).str.slice(0, n)
         )
 
     def tail(self, n: int = 5) -> ExprT:
@@ -356,11 +346,10 @@ class ExprStringNamespace(Generic[ExprT]):
             lyrics: [["taata","taatatata","zukkyun"]]
             lyrics_tail: [["taata","atata","kkyun"]]
         """
-        return self._expr.__class__(
+        return self._expr._with_callable(
             lambda plx: self._expr._to_compliant_expr(plx).str.slice(
                 offset=-n, length=None
-            ),
-            self._expr._metadata,
+            )
         )
 
     def to_datetime(self, format: str | None = None) -> ExprT:
@@ -404,9 +393,8 @@ class ExprStringNamespace(Generic[ExprT]):
             |└─────────────────────┘|
             └───────────────────────┘
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).str.to_datetime(format=format),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).str.to_datetime(format=format)
         )
 
     def to_uppercase(self) -> ExprT:
@@ -434,9 +422,8 @@ class ExprStringNamespace(Generic[ExprT]):
             |1   None      None|
             └──────────────────┘
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).str.to_uppercase(),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).str.to_uppercase()
         )
 
     def to_lowercase(self) -> ExprT:
@@ -459,7 +446,6 @@ class ExprStringNamespace(Generic[ExprT]):
             |1   None      None|
             └──────────────────┘
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).str.to_lowercase(),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).str.to_lowercase()
         )
diff --git a/narwhals/expr_struct.py b/narwhals/expr_struct.py
index d49168f2..37c62d49 100644
--- a/narwhals/expr_struct.py
+++ b/narwhals/expr_struct.py
@@ -50,7 +50,6 @@ class ExprStructNamespace(Generic[ExprT]):
             |└──────────────┴──────┘|
             └───────────────────────┘
         """
-        return self._expr.__class__(
-            lambda plx: self._expr._to_compliant_expr(plx).struct.field(name),
-            self._expr._metadata,
+        return self._expr._with_callable(
+            lambda plx: self._expr._to_compliant_expr(plx).struct.field(name)
         )
diff --git a/narwhals/series.py b/narwhals/series.py
index 1495ddb9..eacc0635 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -115,7 +115,7 @@ class Series(Generic[IntoSeriesT]):
             >>> s = nw.from_native(s_native, series_only=True)
 
             >>> s.implementation
-            <Implementation.PANDAS: 1>
+            <Implementation.PANDAS: 'pandas'>
 
             >>> s.implementation.is_pandas()
             True
@@ -212,10 +212,10 @@ class Series(Generic[IntoSeriesT]):
         try:
             import pyarrow as pa  # ignore-banned-import
         except ModuleNotFoundError as exc:  # pragma: no cover
-            msg = f"PyArrow>=16.0.0 is required for `Series.__arrow_c_stream__` for object of type {type(native_series)}"
+            msg = f"'pyarrow>=16.0.0' is required for `Series.__arrow_c_stream__` for object of type {type(native_series)}"
             raise ModuleNotFoundError(msg) from exc
         if parse_version(pa) < (16, 0):  # pragma: no cover
-            msg = f"PyArrow>=16.0.0 is required for `Series.__arrow_c_stream__` for object of type {type(native_series)}"
+            msg = f"'pyarrow>=16.0.0' is required for `Series.__arrow_c_stream__` for object of type {type(native_series)}"
             raise ModuleNotFoundError(msg)
         from narwhals._arrow.utils import chunked_array
 
diff --git a/narwhals/series_dt.py b/narwhals/series_dt.py
index 68ea9ffe..72e5a7e0 100644
--- a/narwhals/series_dt.py
+++ b/narwhals/series_dt.py
@@ -653,3 +653,38 @@ class SeriesDateTimeNamespace(Generic[SeriesT]):
         return self._narwhals_series._with_compliant(
             self._narwhals_series._compliant_series.dt.timestamp(time_unit)
         )
+
+    def truncate(self, every: str) -> SeriesT:
+        """Divide the date/datetime range into buckets.
+
+        Arguments:
+            every: Length of bucket. Must be of form `<multiple><unit>`,
+                where `multiple` is a positive integer and `unit` is one of:
+
+                - 'ns': nanosecond.
+                - 'us': microsecond.
+                - 'ms': millisecond.
+                - 's': second.
+                - 'm': minute.
+                - 'h': hour.
+                - 'd': day.
+                - 'mo': month.
+                - 'q': quarter.
+                - 'y': year.
+
+        Returns:
+            Series of data type `Date` or `Datetime`.
+
+        Examples:
+            >>> from datetime import datetime
+            >>> import pandas as pd
+            >>> import narwhals as nw
+            >>> s_native = pd.Series([datetime(2021, 3, 1, 12, 34)])
+            >>> s = nw.from_native(s_native, series_only=True)
+            >>> s.dt.truncate("1h").to_native()
+            0   2021-03-01 12:00:00
+            dtype: datetime64[ns]
+        """
+        return self._narwhals_series._with_compliant(
+            self._narwhals_series._compliant_series.dt.truncate(every)
+        )
diff --git a/narwhals/translate.py b/narwhals/translate.py
index 498d3a1f..cb73c316 100644
--- a/narwhals/translate.py
+++ b/narwhals/translate.py
@@ -565,10 +565,12 @@ def _from_native_impl(  # noqa: PLR0915
     elif is_native_spark_like(native_object):  # pragma: no cover
         ns_spark = version.namespace.from_native_object(native_object)
         if series_only:
-            msg = f"Cannot only use `series_only` with {ns_spark.implementation._alias} DataFrame"
+            msg = (
+                f"Cannot only use `series_only` with {ns_spark.implementation} DataFrame"
+            )
             raise TypeError(msg)
         if eager_only or eager_or_interchange_only:
-            msg = f"Cannot only use `eager_only` or `eager_or_interchange_only` with {ns_spark.implementation._alias} DataFrame"
+            msg = f"Cannot only use `eager_only` or `eager_or_interchange_only` with {ns_spark.implementation} DataFrame"
             raise TypeError(msg)
         return ns_spark.compliant.from_native(native_object).to_narwhals()
 
diff --git a/narwhals/utils.py b/narwhals/utils.py
index 81cc3dbf..9011d547 100644
--- a/narwhals/utils.py
+++ b/narwhals/utils.py
@@ -26,6 +26,7 @@ from typing import cast
 from typing import overload
 from warnings import warn
 
+from narwhals._enum import NoAutoEnum
 from narwhals.dependencies import get_cudf
 from narwhals.dependencies import get_dask
 from narwhals.dependencies import get_dask_dataframe
@@ -246,35 +247,37 @@ class Version(Enum):
         return SeriesV1
 
 
-class Implementation(Enum):
+class Implementation(NoAutoEnum):
     """Implementation of native object (pandas, Polars, PyArrow, ...)."""
 
-    PANDAS = auto()
-    """Pandas implementation."""
-    MODIN = auto()
+    PANDAS = "pandas"
+    """pandas implementation."""
+    MODIN = "modin"
     """Modin implementation."""
-    CUDF = auto()
+    CUDF = "cudf"
     """cuDF implementation."""
-    PYARROW = auto()
+    PYARROW = "pyarrow"
     """PyArrow implementation."""
-    PYSPARK = auto()
+    PYSPARK = "pyspark"
     """PySpark implementation."""
-    POLARS = auto()
+    POLARS = "polars"
     """Polars implementation."""
-    DASK = auto()
+    DASK = "dask"
     """Dask implementation."""
-    DUCKDB = auto()
+    DUCKDB = "duckdb"
     """DuckDB implementation."""
-    IBIS = auto()
+    IBIS = "ibis"
     """Ibis implementation."""
-    SQLFRAME = auto()
+    SQLFRAME = "sqlframe"
     """SQLFrame implementation."""
-    PYSPARK_CONNECT = auto()
+    PYSPARK_CONNECT = "pyspark[connect]"
     """PySpark Connect implementation."""
-
-    UNKNOWN = auto()
+    UNKNOWN = "unknown"
     """Unknown implementation."""
 
+    def __str__(self) -> str:
+        return str(self.value)
+
     @classmethod
     def from_native_namespace(
         cls: type[Self], native_namespace: ModuleType
@@ -314,20 +317,10 @@ class Implementation(Enum):
         Returns:
             Implementation.
         """
-        mapping = {
-            "pandas": Implementation.PANDAS,
-            "modin": Implementation.MODIN,
-            "cudf": Implementation.CUDF,
-            "pyarrow": Implementation.PYARROW,
-            "pyspark": Implementation.PYSPARK,
-            "polars": Implementation.POLARS,
-            "dask": Implementation.DASK,
-            "duckdb": Implementation.DUCKDB,
-            "ibis": Implementation.IBIS,
-            "sqlframe": Implementation.SQLFRAME,
-            "pyspark_connect": Implementation.PYSPARK_CONNECT,
-        }
-        return mapping.get(backend_name, Implementation.UNKNOWN)
+        try:
+            return cls(backend_name)
+        except ValueError:
+            return Implementation.UNKNOWN
 
     @classmethod
     def from_backend(
@@ -395,9 +388,9 @@ class Implementation(Enum):
             return sqlframe
 
         if self is Implementation.PYSPARK_CONNECT:  # pragma: no cover
-            import pyspark.sql  # ignore-banned-import
+            import pyspark.sql.connect  # ignore-banned-import
 
-            return pyspark.sql
+            return pyspark.sql.connect
 
         msg = "Not supported Implementation"  # pragma: no cover
         raise AssertionError(msg)
@@ -618,28 +611,6 @@ class Implementation(Enum):
         """
         return self is Implementation.SQLFRAME  # pragma: no cover
 
-    @property
-    def _alias(self) -> LiteralString:
-        """Friendly name for errors.
-
-        Returns:
-            String.
-        """
-        mapping: dict[Implementation, LiteralString] = {
-            Implementation.PANDAS: "Pandas",
-            Implementation.POLARS: "Polars",
-            Implementation.DASK: "Dask",
-            Implementation.IBIS: "Ibis",
-            Implementation.MODIN: "Modin",
-            Implementation.CUDF: "cuDF",
-            Implementation.PYARROW: "PyArrow",
-            Implementation.PYSPARK: "PySpark",
-            Implementation.DUCKDB: "DuckDB",
-            Implementation.SQLFRAME: "SQLFrame",
-            Implementation.PYSPARK_CONNECT: "PySpark Connect",
-        }
-        return mapping[self]
-
     def _backend_version(self) -> tuple[int, ...]:
         native = self.to_native_namespace()
         into_version: Any
@@ -1740,7 +1711,7 @@ def _into_arrow_table(data: IntoArrowTable, context: _FullContext, /) -> pa.Tabl
         ns = ArrowNamespace(backend_version=parse_version(pa), version=version)
         return ns._dataframe.from_arrow(data, context=ns).native
     else:  # pragma: no cover
-        msg = f"PyArrow>=14.0.0 is required for `from_arrow` for object of type {type(data).__name__!r}."
+        msg = f"'pyarrow>=14.0.0' is required for `from_arrow` for object of type {type(data).__name__!r}."
         raise ModuleNotFoundError(msg)
 
 
@@ -1900,7 +1871,7 @@ class requires:  # noqa: N801
         >>> backend.really_complex_feature()
         Traceback (most recent call last):
             ...
-        NotImplementedError: `really_complex_feature` is only available in PyArrow>='9000.0.0', found version '20.0.0'.
+        NotImplementedError: `really_complex_feature` is only available in 'pyarrow>=9000.0.0', found version '20.0.0'.
     """
 
     _min_version: tuple[int, ...]
@@ -1930,10 +1901,10 @@ class requires:  # noqa: N801
         if instance._backend_version >= self._min_version:
             return
         method = self._wrapped_name
-        backend = instance._implementation._alias
+        backend = instance._implementation
         minimum = self._unparse_version(self._min_version)
         found = self._unparse_version(instance._backend_version)
-        msg = f"`{method}` is only available in {backend}>={minimum!r}, found version {found!r}."
+        msg = f"`{method}` is only available in '{backend}>={minimum}', found version {found!r}."
         if self._hint:
             msg = f"{msg}\n{self._hint}"
         raise NotImplementedError(msg)
diff --git a/pyproject.toml b/pyproject.toml
index 2fde66d6..a65a48ce 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -225,6 +225,8 @@ filterwarnings = [
   "ignore:.*__array__ implementation doesn't accept a copy keyword, so passing copy=False failed:DeprecationWarning:modin",
   # raised internally by pandas
   "ignore:.*np.find_common_type is deprecated:DeprecationWarning:pandas",
+  # Warning raised when calling PandasLikeNamespace.from_arrow with old pyarrow
+  "ignore:.*is_sparse is deprecated and will be removed in a future version.*:DeprecationWarning:pyarrow",
 ]
 xfail_strict = true
 markers = ["slow: marks tests as slow (deselect with '-m \"not slow\"')"]


Test patch:
diff --git a/tests/dtypes_test.py b/tests/dtypes_test.py
index d1f403b5..88d48b0a 100644
--- a/tests/dtypes_test.py
+++ b/tests/dtypes_test.py
@@ -470,7 +470,7 @@ def test_enum_repr() -> None:
     result = nw.Enum(["a", "b"])
     assert "Enum(categories=['a', 'b'])" in repr(result)
     result = nw.Enum(nw.Implementation)
-    assert "Enum(categories=[1, 2, 3" in repr(result)
+    assert "Enum(categories=['pandas', 'modin', 'cudf'" in repr(result)
 
 
 def test_enum_hash() -> None:
diff --git a/tests/enum_test.py b/tests/enum_test.py
new file mode 100644
index 00000000..a922329e
--- /dev/null
+++ b/tests/enum_test.py
@@ -0,0 +1,17 @@
+from __future__ import annotations
+
+from enum import auto
+
+import pytest
+
+from narwhals._enum import NoAutoEnum
+
+
+def test_no_auto_enum() -> None:
+    with pytest.raises(
+        ValueError,
+        match=r"Creating values with `auto\(\)` is not allowed. Please provide a value manually instead.",
+    ):
+
+        class TestWithAuto(NoAutoEnum):
+            AUTO = auto()
diff --git a/tests/expr_and_series/dt/truncate_test.py b/tests/expr_and_series/dt/truncate_test.py
new file mode 100644
index 00000000..c537ede4
--- /dev/null
+++ b/tests/expr_and_series/dt/truncate_test.py
@@ -0,0 +1,274 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+import pandas as pd
+import polars as pl
+import pytest
+
+import narwhals as nw
+from tests.utils import Constructor
+from tests.utils import ConstructorEager
+from tests.utils import assert_equal_data
+
+data = {
+    "a": [
+        datetime(2021, 3, 1, 12, 34, 56, 49012),
+        datetime(2020, 1, 2, 2, 4, 14, 715123),
+    ],
+}
+
+
+@pytest.mark.parametrize(
+    ("every", "expected"),
+    [
+        (
+            "1ns",
+            [
+                datetime(2021, 3, 1, 12, 34, 56, 49012),
+                datetime(2020, 1, 2, 2, 4, 14, 715123),
+            ],
+        ),
+        (
+            "1us",
+            [
+                datetime(2021, 3, 1, 12, 34, 56, 49012),
+                datetime(2020, 1, 2, 2, 4, 14, 715123),
+            ],
+        ),
+        (
+            "1ms",
+            [
+                datetime(2021, 3, 1, 12, 34, 56, 49000),
+                datetime(2020, 1, 2, 2, 4, 14, 715000),
+            ],
+        ),
+        (
+            "1s",
+            [
+                datetime(2021, 3, 1, 12, 34, 56),
+                datetime(2020, 1, 2, 2, 4, 14),
+            ],
+        ),
+        (
+            "1m",
+            [
+                datetime(2021, 3, 1, 12, 34),
+                datetime(2020, 1, 2, 2, 4),
+            ],
+        ),
+        (
+            "1h",
+            [
+                datetime(2021, 3, 1, 12, 0, 0, 0),
+                datetime(2020, 1, 2, 2, 0, 0, 0),
+            ],
+        ),
+        (
+            "1d",
+            [
+                datetime(2021, 3, 1),
+                datetime(2020, 1, 2),
+            ],
+        ),
+        (
+            "1mo",
+            [
+                datetime(2021, 3, 1),
+                datetime(2020, 1, 1),
+            ],
+        ),
+        (
+            "1q",
+            [
+                datetime(2021, 1, 1),
+                datetime(2020, 1, 1),
+            ],
+        ),
+        (
+            "1y",
+            [
+                datetime(2021, 1, 1),
+                datetime(2020, 1, 1),
+            ],
+        ),
+    ],
+)
+def test_truncate(
+    request: pytest.FixtureRequest,
+    constructor: Constructor,
+    every: str,
+    expected: list[datetime],
+) -> None:
+    if any(x in str(constructor) for x in ("sqlframe",)):
+        request.applymarker(
+            pytest.mark.xfail(reason="https://github.com/eakmanrq/sqlframe/issues/383")
+        )
+    if every.endswith("ns") and any(x in str(constructor) for x in ("polars", "duckdb")):
+        request.applymarker(pytest.mark.xfail())
+    if any(every.endswith(x) for x in ("mo", "q", "y")) and any(
+        x in str(constructor) for x in ("dask", "cudf")
+    ):
+        request.applymarker(pytest.mark.xfail(reason="Not implemented"))
+    df = nw.from_native(constructor(data))
+    result = df.select(nw.col("a").dt.truncate(every))
+    assert_equal_data(result, {"a": expected})
+
+
+@pytest.mark.parametrize(
+    ("every", "expected"),
+    [
+        (
+            "2ns",
+            [
+                datetime(2021, 3, 1, 12, 34, 56, 49012),
+                datetime(2020, 1, 2, 2, 4, 14, 715123),
+            ],
+        ),
+        (
+            "2us",
+            [
+                datetime(2021, 3, 1, 12, 34, 56, 49012),
+                datetime(2020, 1, 2, 2, 4, 14, 715122),
+            ],
+        ),
+        (
+            "2ms",
+            [
+                datetime(2021, 3, 1, 12, 34, 56, 48000),
+                datetime(2020, 1, 2, 2, 4, 14, 714000),
+            ],
+        ),
+        (
+            "10s",
+            [
+                datetime(2021, 3, 1, 12, 34, 50),
+                datetime(2020, 1, 2, 2, 4, 10),
+            ],
+        ),
+        (
+            "7m",
+            [
+                datetime(2021, 3, 1, 12, 30),
+                datetime(2020, 1, 2, 1, 59),
+            ],
+        ),
+        (
+            "7h",
+            [
+                datetime(2021, 3, 1, 9, 0, 0, 0),
+                datetime(2020, 1, 2, 0, 0, 0, 0),
+            ],
+        ),
+        (
+            "13d",
+            [
+                datetime(2021, 2, 23),
+                datetime(2019, 12, 22),
+            ],
+        ),
+        (
+            "3mo",
+            [
+                datetime(2021, 1, 1),
+                datetime(2020, 1, 1),
+            ],
+        ),
+        (
+            "2q",
+            [
+                datetime(2021, 1, 1),
+                datetime(2020, 1, 1),
+            ],
+        ),
+    ],
+)
+def test_truncate_custom(
+    request: pytest.FixtureRequest,
+    constructor: Constructor,
+    every: str,
+    expected: list[datetime],
+) -> None:
+    if any(x in str(constructor) for x in ("sqlframe",)):
+        request.applymarker(
+            pytest.mark.xfail(reason="https://github.com/eakmanrq/sqlframe/issues/383")
+        )
+    if every.endswith("ns") and any(x in str(constructor) for x in ("polars", "duckdb")):
+        request.applymarker(pytest.mark.xfail())
+    if any(every.endswith(x) for x in ("mo", "q", "y")) and any(
+        x in str(constructor) for x in ("dask", "cudf")
+    ):
+        request.applymarker(pytest.mark.xfail(reason="Not implemented"))
+    df = nw.from_native(constructor(data))
+    result = df.select(nw.col("a").dt.truncate(every))
+    assert_equal_data(result, {"a": expected})
+
+
+@pytest.mark.parametrize(
+    ("every", "expected"),
+    [
+        (
+            "1ns",
+            [
+                datetime(2021, 3, 1, 12, 34, 56, 49012),
+                datetime(2020, 1, 2, 2, 4, 14, 715123),
+            ],
+        ),
+        (
+            "7ns",
+            [
+                datetime(2021, 3, 1, 12, 34, 56, 49012),
+                datetime(2020, 1, 2, 2, 4, 14, 715122),
+            ],
+        ),
+    ],
+)
+def test_truncate_polars_ns(every: str, expected: list[datetime]) -> None:
+    df_pl = pl.DataFrame(data, schema={"a": pl.Datetime(time_unit="ns")})
+    df = nw.from_native(df_pl)
+    result = df.select(nw.col("a").dt.truncate(every))
+    assert_equal_data(result, {"a": expected})
+
+
+def test_truncate_series(
+    constructor_eager: ConstructorEager,
+) -> None:
+    df = nw.from_native(constructor_eager(data), eager_only=True)
+    result = df.select(df["a"].dt.truncate("1h"))
+    expected = {
+        "a": [
+            datetime(2021, 3, 1, 12, 0, 0, 0),
+            datetime(2020, 1, 2, 2, 0, 0, 0),
+        ]
+    }
+    assert_equal_data(result, expected)
+
+
+def test_truncate_invalid_interval(constructor: Constructor) -> None:
+    df = nw.from_native(constructor(data))
+    msg = "Invalid `every` string"
+    with pytest.raises(ValueError, match=msg):
+        df.select(nw.col("a").dt.truncate("1r"))
+
+
+def test_truncate_invalid_multiple(constructor: Constructor) -> None:
+    df = nw.from_native(constructor(data))
+    msg = "Only the following multiples are supported"
+    msg_year = "Only multiple 1 is currently supported for 'y' unit"
+    with pytest.raises(ValueError, match=msg):
+        df.select(nw.col("a").dt.truncate("5mo"))
+    with pytest.raises(ValueError, match=msg):
+        df.select(nw.col("a").dt.truncate("5q"))
+    with pytest.raises(ValueError, match=msg_year):
+        df.select(nw.col("a").dt.truncate("5y"))
+
+
+def test_pandas_numpy_nat() -> None:
+    # The pandas implementation goes via NumPy, so check NaT are preserved.
+    df = nw.from_native(
+        pd.DataFrame({"a": [datetime(2020, 1, 1), None, datetime(2020, 1, 2)]})
+    )
+    result: nw.DataFrame[pd.DataFrame] = df.select(nw.col("a").dt.truncate("1mo"))
+    expected = {"a": [datetime(2020, 1, 1), None, datetime(2020, 1, 1)]}
+    assert_equal_data(result, expected)
+    assert result.item(1, 0) is pd.NaT
diff --git a/tests/expr_and_series/nth_test.py b/tests/expr_and_series/nth_test.py
index 22f44934..0c8a6801 100644
--- a/tests/expr_and_series/nth_test.py
+++ b/tests/expr_and_series/nth_test.py
@@ -40,6 +40,6 @@ def test_nth_not_supported() -> None:  # pragma: no cover
     import polars as pl
 
     df = nw.from_native(pl.DataFrame(data))
-    pattern = re.compile(r"`nth` .+ Polars>=\'1.0.0\'.+`col`.+instead", re.DOTALL)
+    pattern = re.compile(r"`nth` .+ \'polars>=1.0.0\'.+`col`.+instead", re.DOTALL)
     with pytest.raises(NotImplementedError, match=pattern):
         df.select(nw.nth(0))
diff --git a/tests/implementation_test.py b/tests/implementation_test.py
index b4c05cd4..c6f7ccff 100644
--- a/tests/implementation_test.py
+++ b/tests/implementation_test.py
@@ -31,3 +31,24 @@ def test_implementation_polars() -> None:
     ].implementation.is_pandas()
     assert nw.from_native(pl.DataFrame({"a": [1, 2, 3]})).implementation.is_polars()
     assert nw.from_native(pl.LazyFrame({"a": [1, 2, 3]})).implementation.is_polars()
+
+
+@pytest.mark.parametrize(
+    ("member", "value"),
+    [
+        ("PANDAS", "pandas"),
+        ("MODIN", "modin"),
+        ("CUDF", "cudf"),
+        ("PYARROW", "pyarrow"),
+        ("PYSPARK", "pyspark"),
+        ("POLARS", "polars"),
+        ("DASK", "dask"),
+        ("DUCKDB", "duckdb"),
+        ("IBIS", "ibis"),
+        ("SQLFRAME", "sqlframe"),
+        ("PYSPARK_CONNECT", "pyspark[connect]"),
+        ("UNKNOWN", "unknown"),
+    ],
+)
+def test_implementation_new(member: str, value: str) -> None:
+    assert nw.Implementation(value) is getattr(nw.Implementation, member)
diff --git a/tests/no_imports_test.py b/tests/no_imports_test.py
index 07a1b073..fdb24ec5 100644
--- a/tests/no_imports_test.py
+++ b/tests/no_imports_test.py
@@ -15,6 +15,7 @@ def test_polars(monkeypatch: pytest.MonkeyPatch) -> None:
     monkeypatch.delitem(sys.modules, "pandas")
     monkeypatch.delitem(sys.modules, "numpy")
     monkeypatch.delitem(sys.modules, "pyarrow", raising=False)
+    monkeypatch.delitem(sys.modules, "typing_extensions", raising=False)
     monkeypatch.delitem(sys.modules, "duckdb", raising=False)
     monkeypatch.delitem(sys.modules, "dask", raising=False)
     monkeypatch.delitem(sys.modules, "ibis", raising=False)
@@ -31,6 +32,7 @@ def test_polars(monkeypatch: pytest.MonkeyPatch) -> None:
     assert "ibis" not in sys.modules
     assert "pyspark" not in sys.modules
     assert "duckdb" not in sys.modules
+    assert "typing_extensions" not in sys.modules
 
 
 def test_pandas(monkeypatch: pytest.MonkeyPatch) -> None:
diff --git a/tests/utils_test.py b/tests/utils_test.py
index 4339bd91..c48f34a9 100644
--- a/tests/utils_test.py
+++ b/tests/utils_test.py
@@ -541,15 +541,15 @@ def test_requires() -> None:
 
     converted = v_201.to_int()
     assert converted == 123
-    match = r"`to_int`.+Polars>=\'1.0.0\'.+found.+\'0.5\'"
+    match = r"`to_int`.+\'polars>=1.0.0\'.+found.+\'0.5\'"
     with pytest.raises(NotImplementedError, match=match):
         v_05.to_int()
     repeated = v_300.repeat(3)
     assert repeated == "123123123"
-    match = r"`repeat`.+Polars>=\'3.0.0\'.+found.+\'2.0.1\'"
+    match = r"`repeat`.+\'polars>=3.0.0\'.+found.+\'2.0.1\'"
     with pytest.raises(NotImplementedError, match=match):
         v_201.repeat(3)
-    match = r"`repeat`.+Polars>=\'3.0.0\'.+found.+\'0.5\'"
+    match = r"`repeat`.+\'polars>=3.0.0\'.+found.+\'0.5\'"
     with pytest.raises(NotImplementedError, match=match):
         v_05.repeat(3)
     joined = v_201.concat("456", "789")
@@ -558,7 +558,7 @@ def test_requires() -> None:
     assert joined_sep == "123 456 789"
     assert v_300.concat("forever") == "123forever"
     pattern = re.compile(
-        r"`concat`.+Polars>=\'2\'.+found.+\'0.5\'.+Something helpful I suppose", re.DOTALL
+        r"`concat`.+\'polars>=2\'.+found.+\'0.5\'.+Something helpful I suppose", re.DOTALL
     )
     with pytest.raises(NotImplementedError, match=pattern):
         v_05.concat("never")