Skip to content

Commit

Permalink
add floordiv and mod operators to TimeDelta
Browse files Browse the repository at this point in the history
  • Loading branch information
ariebovenberg committed Feb 20, 2025
1 parent 6bac1f7 commit fe053eb
Show file tree
Hide file tree
Showing 6 changed files with 190 additions and 3 deletions.
8 changes: 6 additions & 2 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
🚀 Changelog
============

0.7.0 (2025-02-??)
0.7.0 (2025-02-20)
------------------

This release adds rounding functionality,
along with a small breaking change (see below).

**Breaking changes**

- ``TimeDelta.py_timedelta()`` now truncates nanoseconds to microseconds
Expand All @@ -12,7 +15,8 @@

**Added**

- Added ``round()`` to time and ``TimeDelta`` classes
- Added ``round()`` to all datetime, ``Instant``, and ``TimeDelta`` classes
- Add floor division and modulo operators to ``TimeDelta``
- Add ``is_ambiguous()``, ``day_length()`` and ``start_of_day()`` to ``SystemDateTime``,
for consistency with ``ZonedDateTime``.
- Improvements to documentation
Expand Down
17 changes: 17 additions & 0 deletions docs/overview.rst
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,23 @@ Here is a summary of the arithmetic features for each type:
restrictions to get the result they want, **whenever** provides the
``ignore_dst`` option to at least make it explicit when this is happening.

Rounding
~~~~~~~~

It's often useful to truncate or round a datetime to a specific unit.
For example, you might want to round a datetime to the nearest hour,
or truncate it into 15-minute intervals.

The :class:`~whenever._KnowsLocal.round` method allows you to do this:

.. code-block:: python
>>> d = LocalDateTime(2023, 12, 28, 11, 30)
>>> d.round(hours=1)
LocalDateTime(2023-12-28 12:00:00)
>>> d.round(minutes=15)
LocalDateTime(2023-12-28 11:30:00)
Formatting and parsing
----------------------

Expand Down
2 changes: 2 additions & 0 deletions pysrc/whenever/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,8 @@ class TimeDelta:
def __truediv__(self, other: float) -> TimeDelta: ...
@overload
def __truediv__(self, other: TimeDelta) -> float: ...
def __floordiv__(self, other: TimeDelta) -> int: ...
def __mod__(self, other: TimeDelta) -> TimeDelta: ...
def __abs__(self) -> TimeDelta: ...

@final
Expand Down
31 changes: 31 additions & 0 deletions pysrc/whenever/_pywhenever.py
Original file line number Diff line number Diff line change
Expand Up @@ -1682,13 +1682,44 @@ def __truediv__(self, other: float | TimeDelta) -> TimeDelta | float:
TimeDelta(00:36:00)
>>> d / TimeDelta(minutes=30)
3.0
Note
----
Because TimeDelta is limited to nanosecond precision, the result of
division may not be exact.
"""
if isinstance(other, TimeDelta):
return self._total_ns / other._total_ns
elif isinstance(other, (int, float)):
return TimeDelta(nanoseconds=int(self._total_ns / other))
return NotImplemented

def __floordiv__(self, other: TimeDelta) -> int:
"""Floor division by another delta
Example
-------
>>> d = TimeDelta(hours=1, minutes=39)
>>> d // time_delta(minutes=15)
6
"""
if not isinstance(other, TimeDelta):
return NotImplemented
return self._total_ns // other._total_ns

def __mod__(self, other: TimeDelta) -> TimeDelta:
"""Modulo by another delta
Example
-------
>>> d = TimeDelta(hours=1, minutes=39)
>>> d % TimeDelta(minutes=15)
TimeDelta(00:09:00)
"""
if not isinstance(other, TimeDelta):
return NotImplemented
return TimeDelta(nanoseconds=self._total_ns % other._total_ns)

def __abs__(self) -> TimeDelta:
"""The absolute value
Expand Down
41 changes: 41 additions & 0 deletions src/time_delta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,45 @@ unsafe fn __truediv__(slf: *mut PyObject, factor_obj: *mut PyObject) -> PyReturn
.to_obj(Py_TYPE(slf))
}

unsafe fn __floordiv__(a_obj: *mut PyObject, b_obj: *mut PyObject) -> PyReturn {
if Py_TYPE(b_obj) == Py_TYPE(a_obj) {
// NOTE: we can't avoid using i128 *in general*, because the divisor
// may be 1 nanosecond and the dividend TimeDelta.MAX
let a = TimeDelta::extract(a_obj).total_nanos();
let b = TimeDelta::extract(b_obj).total_nanos();
if b == 0 {
Err(py_err!(PyExc_ZeroDivisionError, "Division by zero"))?
}
let mut result = a / b;
// Adjust for "correct" (Python style) floor division with mixed signs
if a.signum() != b.signum() && a % b != 0 {
result -= 1;
}
result.to_py()
} else {
Ok(newref(Py_NotImplemented()))
}
}

unsafe fn __mod__(a_obj: *mut PyObject, b_obj: *mut PyObject) -> PyReturn {
let type_a = Py_TYPE(a_obj);
if type_a == Py_TYPE(b_obj) {
let a = TimeDelta::extract(a_obj).total_nanos();
let b = TimeDelta::extract(b_obj).total_nanos();
if b == 0 {
Err(py_err!(PyExc_ZeroDivisionError, "Division by zero"))?
}
let mut result = a % b;
// Adjust for "correct" (Python style) floor division with mixed signs
if a.signum() != b.signum() && result != 0 {
result += b;
}
TimeDelta::from_nanos_unchecked(result).to_obj(type_a)
} else {
Ok(newref(Py_NotImplemented()))
}
}

unsafe fn __add__(obj_a: *mut PyObject, obj_b: *mut PyObject) -> PyReturn {
_add_operator(obj_a, obj_b, false)
}
Expand Down Expand Up @@ -549,6 +588,8 @@ static mut SLOTS: &[PyType_Slot] = &[
slotmethod!(Py_nb_add, __add__, 2),
slotmethod!(Py_nb_subtract, __sub__, 2),
slotmethod!(Py_nb_absolute, __abs__, 1),
slotmethod!(Py_nb_floor_divide, __floordiv__, 2),
slotmethod!(Py_nb_remainder, __mod__, 2),
PyType_Slot {
slot: Py_tp_doc,
pfunc: doc::TIMEDELTA.as_ptr() as *mut c_void,
Expand Down
94 changes: 93 additions & 1 deletion tests/test_time_delta.py
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,7 @@ def test_by_number(self):
assert TimeDelta.MAX / 1.0 == TimeDelta.MAX
assert TimeDelta.MIN / 1.0 == TimeDelta.MIN

def test_divide_by_duration(self):
def test_divide_by_timedelta(self):
d = TimeDelta(hours=1, minutes=2, seconds=3, microseconds=4)
assert d / TimeDelta(hours=1) == approx(
1 + 2 / 60 + 3 / 3_600 + 4 / 3_600_000_000
Expand Down Expand Up @@ -498,6 +498,98 @@ def test_invalid(self):
"invalid" / d # type: ignore[operator]


class TestFloorDiv:

def test_examples(self):
d = TimeDelta(hours=3, minutes=40, seconds=3, microseconds=4)
assert d // TimeDelta(hours=1) == 3
assert d // TimeDelta(minutes=5) == 44
assert d // TimeDelta(minutes=-5) == -45
assert -d // TimeDelta(minutes=5) == -45
assert -d // TimeDelta(minutes=-5) == 44

# sub-second dividend
assert d // TimeDelta(microseconds=-9) == -1467000001
assert -d // TimeDelta(microseconds=9) == -1467000001
assert -d // TimeDelta(microseconds=-9) == 1467000000
assert d // TimeDelta(microseconds=9) == 1467000000

# extreme cases
assert TimeDelta.ZERO // TimeDelta.MAX == 0
assert TimeDelta.ZERO // TimeDelta.MIN == 0
assert TimeDelta.MAX // TimeDelta.MAX == 1
assert TimeDelta.MIN // TimeDelta.MIN == 1
assert TimeDelta.MAX // TimeDelta.MIN == -1
assert TimeDelta.MIN // TimeDelta.MAX == -1
# result larger than i64
assert (
TimeDelta.MAX // TimeDelta(nanoseconds=1) == 316192377600000000000
)

def test_divide_by_zero(self):
d = TimeDelta(hours=1, minutes=2, seconds=3, microseconds=4)
with pytest.raises(ZeroDivisionError):
d // TimeDelta()

def test_invalid(self):
d = TimeDelta(hours=1, minutes=2, seconds=3, microseconds=4)
with pytest.raises(TypeError):
d // "invalid" # type: ignore[operator]

with pytest.raises(TypeError):
"invalid" // d # type: ignore[operator]


class TestRemainder:

def test_examples(self):
d = TimeDelta(hours=3, minutes=40, seconds=3, microseconds=4)
assert d % TimeDelta(hours=1) == TimeDelta(
minutes=40, seconds=3, microseconds=4
)
assert d % TimeDelta(minutes=5) == TimeDelta(seconds=3, microseconds=4)
assert d % TimeDelta(minutes=-5) == TimeDelta(
minutes=-5, seconds=3, microseconds=4
)
assert -d % TimeDelta(minutes=5) == TimeDelta(
minutes=5, seconds=-3, microseconds=-4
)
assert -d % TimeDelta(minutes=-5) == TimeDelta(
seconds=-3, microseconds=-4
)

# sub-second dividend
assert d % TimeDelta(microseconds=-9) == TimeDelta(microseconds=-5)
assert -d % TimeDelta(microseconds=9) == TimeDelta(microseconds=5)
assert -d % TimeDelta(microseconds=-9) == TimeDelta(microseconds=-4)
assert d % TimeDelta(microseconds=9) == TimeDelta(microseconds=4)

# extreme cases
assert TimeDelta.ZERO % TimeDelta.MAX == TimeDelta.ZERO
assert TimeDelta.ZERO % TimeDelta.MIN == TimeDelta.ZERO
assert TimeDelta.MAX % TimeDelta.MAX == TimeDelta.ZERO
assert TimeDelta.MIN % TimeDelta.MIN == TimeDelta.ZERO
assert TimeDelta.MAX % TimeDelta.MIN == TimeDelta.ZERO
assert TimeDelta.MIN % TimeDelta.MAX == TimeDelta.ZERO
# result larger than i64
assert (TimeDelta.MAX - TimeDelta(nanoseconds=1)) % TimeDelta.MAX == (
TimeDelta.MAX - TimeDelta(nanoseconds=1)
)

def test_divide_by_zero(self):
d = TimeDelta(hours=1, minutes=2, seconds=3, microseconds=4)
with pytest.raises(ZeroDivisionError):
d % TimeDelta()

def test_invalid(self):
d = TimeDelta(hours=1, minutes=2, seconds=3, microseconds=4)
with pytest.raises(TypeError):
d % "invalid" # type: ignore[operator]

with pytest.raises(TypeError):
5.9 % d # type: ignore[operator]


def test_negate():
assert TimeDelta.ZERO == -TimeDelta.ZERO
assert TimeDelta(
Expand Down

0 comments on commit fe053eb

Please sign in to comment.