Repository: narwhals-dev/narwhals
Pull request #2831 (2025-07-14 11:12:43)

Enh: Accept negative offsets in `Expr.dt.offset_by()`
<!--
# 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 #2573
- Closes #\<issue number\>

## Checklist

- [x] Code follows style guide (ruff)
- [x] Tests added
- [x] Documented the changes

## If you have comments or can explain your changes, please do so below


MarcoGorelli: let's ship it as a gift to @denisdlugach 🎁 😄 

Patch:
diff --git a/narwhals/_duration.py b/narwhals/_duration.py
index d1fe4a6d..8e678be7 100644
--- a/narwhals/_duration.py
+++ b/narwhals/_duration.py
@@ -32,7 +32,7 @@ TimedeltaKwd: TypeAlias = Literal[
 ]
 
 PATTERN_INTERVAL: re.Pattern[str] = re.compile(
-    r"^(?P<multiple>\d+)(?P<unit>ns|us|ms|mo|m|s|h|d|q|y)\Z"
+    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])
diff --git a/narwhals/_ibis/expr_dt.py b/narwhals/_ibis/expr_dt.py
index 35cc9602..57ec3e97 100644
--- a/narwhals/_ibis/expr_dt.py
+++ b/narwhals/_ibis/expr_dt.py
@@ -5,7 +5,11 @@ from typing import TYPE_CHECKING, Any, Callable
 from narwhals._compliant import LazyExprNamespace
 from narwhals._compliant.any_namespace import DateTimeNamespace
 from narwhals._duration import Interval
-from narwhals._ibis.utils import UNITS_DICT_BUCKET, UNITS_DICT_TRUNCATE
+from narwhals._ibis.utils import (
+    UNITS_DICT_BUCKET,
+    UNITS_DICT_TRUNCATE,
+    timedelta_to_ibis_interval,
+)
 from narwhals._utils import not_implemented
 
 if TYPE_CHECKING:
@@ -87,7 +91,7 @@ class IbisExprDateTimeNamespace(
         if unit in {"y", "q", "mo", "d", "ns"}:
             msg = f"Offsetting by {unit} is not yet supported for ibis."
             raise NotImplementedError(msg)
-        offset = interval.to_timedelta()
+        offset = timedelta_to_ibis_interval(interval.to_timedelta())
         return self.compliant._with_callable(lambda expr: expr.add(offset))
 
     def replace_time_zone(self, time_zone: str | None) -> IbisExpr:
diff --git a/narwhals/_ibis/utils.py b/narwhals/_ibis/utils.py
index 5809ecdd..aabab260 100644
--- a/narwhals/_ibis/utils.py
+++ b/narwhals/_ibis/utils.py
@@ -10,6 +10,7 @@ from narwhals._utils import isinstance_or_issubclass
 
 if TYPE_CHECKING:
     from collections.abc import Mapping
+    from datetime import timedelta
 
     import ibis.expr.types as ir
     from ibis.common.temporal import TimestampUnit
@@ -233,3 +234,7 @@ def narwhals_to_native_dtype(  # noqa: C901, PLR0912
         raise NotImplementedError(msg)
     msg = f"Unknown dtype: {dtype}"  # pragma: no cover
     raise AssertionError(msg)
+
+
+def timedelta_to_ibis_interval(td: timedelta) -> ibis.expr.types.temporal.IntervalScalar:
+    return ibis.interval(days=td.days, seconds=td.seconds, microseconds=td.microseconds)


Test patch:
diff --git a/tests/expr_and_series/dt/offset_by_test.py b/tests/expr_and_series/dt/offset_by_test.py
index 5d1c6f7b..3707ff2c 100644
--- a/tests/expr_and_series/dt/offset_by_test.py
+++ b/tests/expr_and_series/dt/offset_by_test.py
@@ -95,6 +95,41 @@ data_dst = {"a": [datetime(2020, 10, 25, tzinfo=timezone.utc)]}
                 datetime(2023, 1, 2, 2, 4, 14, 715123),
             ],
         ),
+        (
+            "-2us",
+            [
+                datetime(2021, 3, 1, 12, 34, 56, 49010),
+                datetime(2020, 1, 2, 2, 4, 14, 715121),
+            ],
+        ),
+        (
+            "-2000ns",
+            [
+                datetime(2021, 3, 1, 12, 34, 56, 49010),
+                datetime(2020, 1, 2, 2, 4, 14, 715121),
+            ],
+        ),
+        (
+            "-7h",
+            [
+                datetime(2021, 3, 1, 5, 34, 56, 49012),
+                datetime(2020, 1, 1, 19, 4, 14, 715123),
+            ],
+        ),
+        (
+            "-13d",
+            [
+                datetime(2021, 2, 16, 12, 34, 56, 49012),
+                datetime(2019, 12, 20, 2, 4, 14, 715123),
+            ],
+        ),
+        (
+            "-3y",
+            [
+                datetime(2018, 3, 1, 12, 34, 56, 49012),
+                datetime(2017, 1, 2, 2, 4, 14, 715123),
+            ],
+        ),
     ],
 )
 def test_offset_by(