Repository: ibis-project/ibis

Issue:
feat: support 2+ arguments to `ibis.interval()`
### Is your feature request related to a problem?

In Ibis, if you want to add 1 year + 1 month + 1 day to a date, you need to do:
```py
ibis.date('2023-04-12') + ibis.interval(years=1) + ibis.interval(months=1) + ibis.interval(days=1)
```
It would be nice if you could instead do:
```py
ibis.date('2023-04-12') + ibis.interval(years=1, months=1, days=1)
```
But this is not implemented and returns:
```
ValueError: Exactly one argument is required
```

### Describe the solution you'd like

Support passing multiple arguments to `ibis.interval()` and add the units together the construct the returned interval.

### What version of ibis are you running?

5.0.0

### What backend(s) are you using, if any?

_No response_

### Code of Conduct

- [X] I agree to follow this project's Code of Conduct


Discussion:
@kszucs Thoughts on this?

Should we implement multiple arguments as the addition of the intervals?
I think this is a reasonable request, we can simply expand `ibis.interval(years=1, months=1, days=1)` to `ibis.interval(years=1) + ibis.interval(months=1) + ibis.interval(days=1)`

Resolving patch:
diff --git a/ibis/expr/api.py b/ibis/expr/api.py
--- a/ibis/expr/api.py
+++ b/ibis/expr/api.py
@@ -717,6 +717,7 @@ def _time_from_deferred(value: Deferred) -> Deferred:
 def interval(
     value: int | datetime.timedelta | None = None,
     unit: str = 's',
+    *,
     years: int | None = None,
     quarters: int | None = None,
     months: int | None = None,
@@ -734,7 +735,7 @@ def interval(
     Parameters
     ----------
     value
-        Interval value. If passed, must be combined with `unit`.
+        Interval value.
     unit
         Unit of `value`
     years
@@ -765,37 +766,46 @@ def interval(
     IntervalScalar
         An interval expression
     """
+    keyword_value_unit = [
+        ("nanoseconds", nanoseconds, "ns"),
+        ("microseconds", microseconds, "us"),
+        ("milliseconds", milliseconds, "ms"),
+        ("seconds", seconds, "s"),
+        ("minutes", minutes, "m"),
+        ("hours", hours, "h"),
+        ("days", days, "D"),
+        ("weeks", weeks, "W"),
+        ("months", months, "M"),
+        ("quarters", quarters, "Q"),
+        ("years", years, "Y"),
+    ]
     if value is not None:
+        for kw, v, _ in keyword_value_unit:
+            if v is not None:
+                raise TypeError(f"Cannot provide both 'value' and '{kw}'")
         if isinstance(value, datetime.timedelta):
-            unit = 's'
-            value = int(value.total_seconds())
-        elif not isinstance(value, int):
-            raise ValueError('Interval value must be an integer')
+            components = [
+                (value.microseconds, "us"),
+                (value.seconds, "s"),
+                (value.days, "D"),
+            ]
+            components = [(v, u) for v, u in components if v]
+        elif isinstance(value, int):
+            components = [(value, unit)]
+        else:
+            raise TypeError("value must be an integer or timedelta")
     else:
-        kwds = [
-            ('Y', years),
-            ('Q', quarters),
-            ('M', months),
-            ('W', weeks),
-            ('D', days),
-            ('h', hours),
-            ('m', minutes),
-            ('s', seconds),
-            ('ms', milliseconds),
-            ('us', microseconds),
-            ('ns', nanoseconds),
-        ]
-        defined_units = [(k, v) for k, v in kwds if v is not None]
-
-        if len(defined_units) != 1:
-            raise ValueError('Exactly one argument is required')
-
-        unit, value = defined_units[0]
-
-    value_type = literal(value).type()
-    type = dt.Interval(unit, value_type=value_type)
-
-    return literal(value, type=type)
+        components = [(v, u) for _, v, u in keyword_value_unit if v is not None]
+
+    # If no components, default to 0 s
+    if not components:
+        components.append((0, "s"))
+
+    intervals = [
+        literal(v, type=dt.Interval(u, value_type=literal(v).type()))
+        for v, u in components
+    ]
+    return functools.reduce(operator.add, intervals)
 
 
 def case() -> bl.SearchedCaseBuilder:


Test patch:
diff --git a/ibis/tests/expr/test_temporal.py b/ibis/tests/expr/test_temporal.py
--- a/ibis/tests/expr/test_temporal.py
+++ b/ibis/tests/expr/test_temporal.py
@@ -10,6 +10,7 @@
 import ibis.expr.types as ir
 from ibis.common.enums import IntervalUnit
 from ibis.expr import api
+from ibis.tests.util import assert_equal
 
 
 def test_temporal_literals():
@@ -20,6 +21,73 @@ def test_temporal_literals():
     assert isinstance(timestamp, ir.TimestampScalar)
 
 
+def test_interval_function_integers():
+    # No args, default to 0 seconds
+    assert_equal(ibis.interval(), ibis.interval(0, "s"))
+
+    # Default unit is seconds
+    assert_equal(ibis.interval(1), ibis.interval(1, "s"))
+
+    # unit is used if provided
+    res = ibis.interval(1, "D")
+    sol = ibis.literal(1, type=dt.Interval("D", value_type=ibis.literal(1).type()))
+    assert_equal(res, sol)
+
+
+def test_interval_function_timedelta():
+    res = ibis.interval(datetime.timedelta())
+    sol = ibis.interval()
+    assert_equal(res, sol)
+
+    res = ibis.interval(datetime.timedelta(microseconds=10))
+    sol = ibis.interval(10, "us")
+    assert_equal(res, sol)
+
+    res = ibis.interval(datetime.timedelta(days=5))
+    sol = ibis.interval(5, "D")
+    assert_equal(res, sol)
+
+    res = ibis.interval(datetime.timedelta(seconds=10, microseconds=2))
+    sol = ibis.interval(2, "us") + ibis.interval(10, "s")
+    assert_equal(res, sol)
+
+
+@pytest.mark.parametrize(
+    "kw,unit",
+    [
+        ("nanoseconds", "ns"),
+        ("microseconds", "us"),
+        ("milliseconds", "ms"),
+        ("seconds", "s"),
+        ("minutes", "m"),
+        ("hours", "h"),
+        ("days", "D"),
+        ("weeks", "W"),
+        ("months", "M"),
+        ("quarters", "Q"),
+        ("years", "Y"),
+    ],
+)
+def test_interval_function_unit_keywords(kw, unit):
+    res = ibis.interval(**{kw: 1})
+    sol = ibis.interval(1, unit)
+    assert_equal(res, sol)
+
+
+def test_interval_function_multiple_keywords():
+    res = ibis.interval(microseconds=10, hours=3, days=2)
+    sol = ibis.interval(10, "us") + ibis.interval(3, "h") + ibis.interval(2, "D")
+    assert_equal(res, sol)
+
+
+def test_interval_function_invalid():
+    with pytest.raises(TypeError, match="integer or timedelta"):
+        ibis.interval(1.5)
+
+    with pytest.raises(TypeError, match="'value' and 'microseconds'"):
+        ibis.interval(1, microseconds=2)
+
+
 @pytest.mark.parametrize(
     ('interval', 'unit', 'expected'),
     [