diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 57e09d8d..bc9bf637 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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) ------------------- diff --git a/Cargo.lock b/Cargo.lock index 9f98914e..17df199e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 3 [[package]] name = "libc" -version = "0.2.161" +version = "0.2.168" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" +checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d" [[package]] name = "once_cell" @@ -16,9 +16,9 @@ checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "pyo3-build-config" -version = "0.22.5" +version = "0.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc38c5feeb496c8321091edf3d63e9a6829eab4b863b4a6a65f26f3e9cc6b179" +checksum = "dc0e0469a84f208e20044b98965e1561028180219e35352a2afaf2b942beff3b" dependencies = [ "once_cell", "target-lexicon", @@ -26,9 +26,9 @@ dependencies = [ [[package]] name = "pyo3-ffi" -version = "0.22.5" +version = "0.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94845622d88ae274d2729fcefc850e63d7a3ddff5e3ce11bd88486db9f1d357d" +checksum = "eb1547a7f9966f6f1a0f0227564a9945fe36b90da5a93b3933fc3dc03fae372d" dependencies = [ "libc", "pyo3-build-config", diff --git a/Cargo.toml b/Cargo.toml index e4d2977c..8e8c0b78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/README.md b/README.md index 8f5e5b6e..063671ae 100644 --- a/README.md +++ b/README.md @@ -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? diff --git a/docs/overview.rst b/docs/overview.rst index eb0d2a92..7346e4dd 100644 --- a/docs/overview.rst +++ b/docs/overview.rst @@ -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") @@ -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:: diff --git a/pyproject.toml b/pyproject.toml index 86434045..1f086acd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ maintainers = [ {name = "Arie Bovenberg", email = "a.c.bovenberg@gmail.com"}, ] readme = "README.md" -version = "0.6.14" +version = "0.6.15" description = "Modern datetime library for Python" requires-python = ">=3.9" classifiers = [ diff --git a/pysrc/whenever/__init__.pyi b/pysrc/whenever/__init__.pyi index a239b441..aaa2faba 100644 --- a/pysrc/whenever/__init__.pyi +++ b/pysrc/whenever/__init__.pyi @@ -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: ... diff --git a/pysrc/whenever/_pywhenever.py b/pysrc/whenever/_pywhenever.py index 932c5724..121a5025 100644 --- a/pysrc/whenever/_pywhenever.py +++ b/pysrc/whenever/_pywhenever.py @@ -32,7 +32,7 @@ # - It saves some overhead from __future__ import annotations -__version__ = "0.6.14" +__version__ = "0.6.15" import enum import re @@ -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 ` for more information. @@ -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 @@ -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 @@ -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) diff --git a/src/common.rs b/src/common.rs index a325a8cd..b7d30fe7 100644 --- a/src/common.rs +++ b/src/common.rs @@ -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, diff --git a/src/date.rs b/src/date.rs index 9f7c723e..2acceb4a 100644 --- a/src/date.rs +++ b/src/date.rs @@ -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, @@ -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(), ]; diff --git a/src/docstrings.rs b/src/docstrings.rs index 5c2dd8b1..1556fb2f 100644 --- a/src/docstrings.rs +++ b/src/docstrings.rs @@ -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) -- @@ -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 ` for more information. diff --git a/tests/test_date.py b/tests/test_date.py index 6a37fd10..4c256a33 100644 --- a/tests/test_date.py +++ b/tests/test_date.py @@ -395,6 +395,30 @@ def test_subtract(d, kwargs, expected): assert d - DateDelta(**kwargs) == expected +class TestDaysUntilAndSince: + + @pytest.mark.parametrize( + "d1, d2, expected", + [ + (Date(2021, 1, 1), Date(2021, 1, 31), 30), + (Date(2020, 2, 28), Date(2020, 2, 28), 0), + (Date(2020, 2, 28), Date(2020, 3, 1), 2), + (Date(2020, 2, 28), Date(2020, 2, 1), -27), + (Date(1990, 5, 2), Date(2021, 12, 1), 11536), + (Date.MIN, Date.MAX, 3652058), + ], + ) + def test_days_until_and_since(self, d1, d2, expected): + assert d1.days_until(d2) == expected + assert d2.days_since(d1) == expected + assert d1.days_since(d2) == -expected + assert d2.days_until(d1) == -expected + + def test_invalid(self): + with pytest.raises((TypeError, AttributeError)): + Date(2021, 1, 1).days_until(LocalDateTime(2021, 1, 1, 1, 2, 3)) # type: ignore[arg-type] + + _EXAMPLE_DATES = [ *chain.from_iterable( [