Skip to content

Commit

Permalink
add Date.days_until() and improve arithmetic docs
Browse files Browse the repository at this point in the history
  • Loading branch information
ariebovenberg committed Dec 11, 2024
1 parent 769adde commit 078a15e
Show file tree
Hide file tree
Showing 12 changed files with 208 additions and 38 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
🚀 Changelog
============

0.6.15 (2024-12-11)
-------------------

- Add ``Date.days_[since|until]`` methods for calculating the difference
between two dates in days only (no months or years)
- Improve docs about arithmetic rules for calendar and time units.

0.6.14 (2024-11-27)
-------------------

Expand Down
12 changes: 6 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ name = "benchmarks"
path = "benchmarks/rust/main.rs"

[dependencies]
pyo3-ffi = { version = "^0.22.0", default-features = false, features = ["extension-module"]}
pyo3-ffi = { version = "^0.23.0", default-features = false, features = ["extension-module"]}

[build-dependencies]
pyo3-build-config = { version = "^0.22.0" }
pyo3-build-config = { version = "^0.23.0" }
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ arrived on the scene in 2016, promising better DST-handling,
as well as improved performance.
However, it only fixes [*some* DST-related pitfalls](https://dev.arie.bovenberg.net/blog/python-datetime-pitfalls/#datetime-library-scorecard),
and its performance has significantly [degraded over time](https://github.com/sdispater/pendulum/issues/818).
Additionally, it hasn't been actively maintained since a breaking 3.0 release last year.
Additionally, it's in maintenance limbo with only one release in the last four years,
and issues piling up unaddressed.

## Why use whenever?

Expand Down
97 changes: 74 additions & 23 deletions docs/overview.rst
Original file line number Diff line number Diff line change
Expand Up @@ -443,15 +443,14 @@ with the ``disambiguate=`` argument:
Arithmetic
----------

Datetimes support various arithmetic operations with addition and subtraction.
Datetimes support various arithmetic operations.

Difference between times
~~~~~~~~~~~~~~~~~~~~~~~~
Difference
~~~~~~~~~~

You can get the duration between two instances with the ``-`` operator or
You can get the duration between two datetimes or instants with the ``-`` operator or
the :meth:`~whenever._KnowsInstant.difference` method.
Exact types can be mixed with each other,
but local datetimes cannot be mixed with exact types:
Exact and local types cannot be mixed, although exact types can be mixed with each other:

>>> # difference between moments in time
>>> Instant.from_utc(2023, 12, 28, 11, 30) - ZonedDateTime(2023, 12, 28, tz="Europe/Amsterdam")
Expand All @@ -465,29 +464,81 @@ TimeDelta(24:00:00)

.. _add-subtract-time:

Adding and subtracting time
~~~~~~~~~~~~~~~~~~~~~~~~~~~
Addition and subtraction
~~~~~~~~~~~~~~~~~~~~~~~~

You can add or subtract various units of time from a datetime instance.

>>> d = ZonedDateTime(2023, 12, 28, 11, 30, tz="Europe/Amsterdam")
>>> d.add(hours=5, minutes=30)
ZonedDateTime(2023-12-28 17:00:00+01:00[Europe/Amsterdam])
>>> d.subtract(days=1, disambiguate="compatible") # 1 day earlier
ZonedDateTime(2023-12-27 11:30:00+01:00[Europe/Amsterdam])

Adding/subtracting takes into account timezone changes (e.g. daylight saving time)
according to industry standard RFC 5545 and other modern datetime libraries.
This means:

- Units are handled from largest (years and months) to smallest (nanosecond),
truncating and/or wrapping at each step.
- Adding or subtracting calendar units (months, days) keeps the local
time of day the same across DST changes.
This is because you'd expect that rescheduling a 10am appointment "a day later"
will still be at 10am, regardless of a DST change overnight.
- Precise time units (hours, minutes, and seconds) account for DST changes.
You wouldn't want a timer set for 2 hours to go off at 1 or 3 hours later instead.

The behavior arithmetic behavior is different for three categories of units:

1. Adding **years and months** may require truncation of the date.
For example, adding a month to August 31st results in September 31st,
which isn't valid. In such cases, the date is truncated to the last day of the month.

.. code-block:: python
>>> d = LocalDateTime(2023, 8, 31, hour=12)
>>> d.add(months=1)
LocalDateTime(2023-09-30 12:00:00)
In case of dealing with :class:`~whenever.ZonedDateTime` or :class:`~whenever.SystemDateTime`,
there is a rare case where the resulting date might land the datetime in the middle of a DST transition.
For this reason, adding years or months to these types requires the ``disambiguate=`` argument:

.. code-block:: python
>>> d = ZonedDateTime(2023, 9, 29, 2, 15, tz="Europe/Amsterdam")
>>> d.add(months=1, disambiguate="raise")
Traceback (most recent call last):
...
whenever.RepeatedTime: The resulting datetime is repeated in tz Europe/Amsterdam
2. Adding **days** only affects the calendar date.
Adding a day to a datetime will not affect the local time of day.
This is usually same as adding 24 hours, **except** during DST transitions!

This behavior may seem strange at first, but it's the most intuitive
when you consider that you'd expect postponing a meeting "to tomorrow"
should still keep the same time of day, regardless of DST changes.
For this reason, this is the behavior of the industry standard RFC 5545
and other modern datetime libraries.

.. code-block:: python
>>> # on the eve of a DST transition
>>> d = ZonedDateTime(2023, 3, 25, hour=12, tz="Europe/Amsterdam")
>>> d.add(days=1, disambiguate="raise") # a day later, still 12 o'clock
ZonedDateTime(2023-03-26 12:00:00+02:00[Europe/Amsterdam])
>>> d.add(hours=24) # 24 hours later (we skipped an hour overnight!)
ZonedDateTime(2023-03-26 13:00:00+02:00[Europe/Amsterdam])
As with months and years, adding days to a :class:`~whenever.ZonedDateTime`
or :class:`~whenever.SystemDateTime` requires the ``disambiguate=`` argument,
since the resulting date might land the datetime in a DST transition.

3. Adding **precise time units** (hours, minutes, seconds) never results
in ambiguity. If an hour is skipped or repeated due to a DST transition,
precise time units will account for this.

.. code-block:: python
>>> d = ZonedDateTime(2023, 3, 25, hour=12, tz="Europe/Amsterdam")
>>> d.add(hours=24) # we skipped an hour overnight!
ZonedDateTime(2023-03-26 13:00:00+02:00[Europe/Amsterdam])
:class:`~whenever.LocalDateTime` also supports adding precise time units,
but requires the ``ignore_dst=True`` argument, to prevent
the common mistake of ignoring DST transitions by ignoring timezones.

.. code-block:: python
>>> d = LocalDateTime(2023, 3, 25, hour=12, tz="Europe/Amsterdam")
>>> d.add(hours=24, ignore_dst=True) # NOT recommended
ZonedDateTime(2023-03-26 13:00:00+02:00[Europe/Amsterdam])
.. seealso::

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ maintainers = [
{name = "Arie Bovenberg", email = "[email protected]"},
]
readme = "README.md"
version = "0.6.14"
version = "0.6.15"
description = "Modern datetime library for Python"
requires-python = ">=3.9"
classifiers = [
Expand Down
2 changes: 2 additions & 0 deletions pysrc/whenever/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ class Date:
def subtract(
self, *, years: int = 0, months: int = 0, weeks: int = 0, days: int = 0
) -> Date: ...
def days_since(self, other: Date, /) -> int: ...
def days_until(self, other: Date, /) -> int: ...
def __add__(self, p: DateDelta) -> Date: ...
@overload
def __sub__(self, d: DateDelta) -> Date: ...
Expand Down
44 changes: 41 additions & 3 deletions pysrc/whenever/_pywhenever.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
# - It saves some overhead
from __future__ import annotations

__version__ = "0.6.14"
__version__ = "0.6.15"

import enum
import re
Expand Down Expand Up @@ -349,7 +349,7 @@ def add(
def subtract(
self, *, years: int = 0, months: int = 0, weeks: int = 0, days: int = 0
) -> Date:
"""Subtract a components from a date.
"""Subtract components from a date.
See :ref:`the docs on arithmetic <arithmetic>` for more information.
Expand All @@ -363,6 +363,38 @@ def subtract(
"""
return self.add(years=-years, months=-months, weeks=-weeks, days=-days)

def days_until(self, other: Date, /) -> int:
"""Calculate the number of days from this date to another date.
If the other date is before this date, the result is negative.
Example
-------
>>> Date(2021, 1, 2).days_until(Date(2021, 1, 5))
3
Note
----
If you're interested in calculating the difference
in terms of days **and** months, use the subtraction operator instead.
"""
return (other._py_date - self._py_date).days

def days_since(self, other: Date, /) -> int:
"""Calculate the number of days this day is after another date.
If the other date is after this date, the result is negative.
Example
-------
>>> Date(2021, 1, 5).days_since(Date(2021, 1, 2))
3
Note
----
If you're interested in calculating the difference
in terms of days **and** months, use the subtraction operator instead.
"""
return (self._py_date - other._py_date).days

def _add_months(self, mos: int) -> Date:
year_overflow, month_new = divmod(self.month - 1 + mos, 12)
month_new += 1
Expand Down Expand Up @@ -400,7 +432,8 @@ def __sub__(self, d: DateDelta | Date) -> Date | DateDelta:
>>> Date(2021, 1, 2) - DateDelta(weeks=1, days=3)
Date(2020-12-26)
The difference between two dates is calculated such that:
The difference between two dates is calculated in months and days,
such that:
>>> delta = d1 - d2
>>> d2 + delta == d1 # always
Expand All @@ -422,6 +455,11 @@ def __sub__(self, d: DateDelta | Date) -> Date | DateDelta:
>>> # the other way around, the result is different
>>> Date(2023, 6, 30) - Date(2024, 3, 31)
DateDelta(-P9M)
Note
----
If you'd like to calculate the difference in days only (no months),
use the :meth:`days_until` or :meth:`days_since` instead.
"""
if isinstance(d, DateDelta):
return self.subtract(months=d._months, days=d._days)
Expand Down
2 changes: 1 addition & 1 deletion src/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ macro_rules! method_vararg(
PyMethodDef {
ml_name: cstr!($name),
ml_meth: PyMethodDefPointer {
_PyCFunctionFast: {
PyCFunctionFast: {
unsafe extern "C" fn _wrap(
slf: *mut PyObject,
args: *mut *mut PyObject,
Expand Down
13 changes: 13 additions & 0 deletions src/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,17 @@ unsafe fn _shift_method(
.to_obj(cls)
}

unsafe fn days_since(a: *mut PyObject, b: *mut PyObject) -> PyReturn {
if Py_TYPE(b) != Py_TYPE(a) {
Err(type_err!("argument must be a whenever.Date"))?
}
(Date::extract(a).ord() as i32 - Date::extract(b).ord() as i32).to_py()
}

unsafe fn days_until(a: *mut PyObject, b: *mut PyObject) -> PyReturn {
days_since(b, a)
}

unsafe fn replace(
slf: *mut PyObject,
cls: *mut PyTypeObject,
Expand Down Expand Up @@ -736,6 +747,8 @@ static mut METHODS: &[PyMethodDef] = &[
method!(__reduce__, c""),
method_kwargs!(add, doc::DATE_ADD),
method_kwargs!(subtract, doc::DATE_SUBTRACT),
method!(days_since, doc::DATE_DAYS_SINCE, METH_O),
method!(days_until, doc::DATE_DAYS_UNTIL, METH_O),
method_kwargs!(replace, doc::DATE_REPLACE),
PyMethodDef::zeroed(),
];
Expand Down
36 changes: 35 additions & 1 deletion src/docstrings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,40 @@ Weekday.SATURDAY
>>> Weekday.SATURDAY.value
6 # the ISO value
";
pub(crate) const DATE_DAYS_SINCE: &CStr = c"\
days_since($self, other, /)
--
Calculate the number of days this day is after another date.
If the other date is after this date, the result is negative.
Example
-------
>>> Date(2021, 1, 5).days_since(Date(2021, 1, 2))
3
Note
----
If you're interested in calculating the difference
in terms of days **and** months, use the subtraction operator instead.
";
pub(crate) const DATE_DAYS_UNTIL: &CStr = c"\
days_until($self, other, /)
--
Calculate the number of days from this date to another date.
If the other date is before this date, the result is negative.
Example
-------
>>> Date(2021, 1, 2).days_until(Date(2021, 1, 5))
3
Note
----
If you're interested in calculating the difference
in terms of days **and** months, use the subtraction operator instead.
";
pub(crate) const DATE_FORMAT_COMMON_ISO: &CStr = c"\
format_common_iso($self)
--
Expand Down Expand Up @@ -313,7 +347,7 @@ pub(crate) const DATE_SUBTRACT: &CStr = c"\
subtract($self, *, years=0, months=0, weeks=0, days=0)
--
Subtract a components from a date.
Subtract components from a date.
See :ref:`the docs on arithmetic <arithmetic>` for more information.
Expand Down
Loading

0 comments on commit 078a15e

Please sign in to comment.