From f9c903c4d0e8fc3205c728743dfc015d9ef2b628 Mon Sep 17 00:00:00 2001 From: Arie Bovenberg Date: Wed, 29 Jan 2025 21:10:20 +0100 Subject: [PATCH] Generalize hours_in_day() to day_length() --- CHANGELOG.rst | 4 +-- Cargo.lock | 8 ++--- docs/api.rst | 2 ++ pysrc/whenever/_pywhenever.py | 18 +++++------ src/date.rs | 60 ++++++++++++++++++----------------- src/docstrings.rs | 28 ++++++++-------- src/time.rs | 10 +----- src/zoned_datetime.rs | 9 +++--- tests/test_zoned_datetime.py | 53 ++++++++++++++++++------------- 9 files changed, 99 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d38eca56..f2557297 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,10 +1,10 @@ 🚀 Changelog ============ -0.6.17 (2025-01-29) +0.6.17 (2025-01-30) ------------------- -- Added ``hours_in_day()`` and ``start_of_day()`` methods to ``ZonedDateTime`` +- Added ``day_length()`` and ``start_of_day()`` methods to ``ZonedDateTime`` to make it easier to work with edge cases around DST transitions. - Fix cases in type stubs where positional-only arguments weren't marked as such diff --git a/Cargo.lock b/Cargo.lock index 2d010cba..e211a5ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16,9 +16,9 @@ checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "pyo3-build-config" -version = "0.23.3" +version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc0e0469a84f208e20044b98965e1561028180219e35352a2afaf2b942beff3b" +checksum = "1cd3927b5a78757a0d71aa9dff669f903b1eb64b54142a9bd9f757f8fde65fd7" dependencies = [ "once_cell", "target-lexicon", @@ -26,9 +26,9 @@ dependencies = [ [[package]] name = "pyo3-ffi" -version = "0.23.3" +version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb1547a7f9966f6f1a0f0227564a9945fe36b90da5a93b3933fc3dc03fae372d" +checksum = "dab6bb2102bd8f991e7749f130a70d05dd557613e39ed2deeee8e9ca0c4d548d" dependencies = [ "libc", "pyo3-build-config", diff --git a/docs/api.rst b/docs/api.rst index ed782616..dc6eafbd 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -84,6 +84,8 @@ Concrete classes :members: tz, is_ambiguous, + start_of_day, + hours_in_day, :member-order: bysource :show-inheritance: diff --git a/pysrc/whenever/_pywhenever.py b/pysrc/whenever/_pywhenever.py index 1eac1a05..49828ba8 100644 --- a/pysrc/whenever/_pywhenever.py +++ b/pysrc/whenever/_pywhenever.py @@ -4310,24 +4310,24 @@ def is_ambiguous(self) -> bool: # ambiguous datetimes are never equal across timezones return self._py_dt.astimezone(_UTC) != self._py_dt - def hours_in_day(self) -> float: - """The number of hours in the day, accounting for timezone transitions, - e.g. during a DST transition. + def day_length(self) -> TimeDelta: + """The duration between the start of the current day and the next. + This is usually 24 hours, but may be different due to timezone transitions. Example ------- - >>> ZonedDateTime(2020, 8, 15, tz="Europe/London").hours_in_day() - 24 - >>> ZonedDateTime(2023, 10, 29, tz="Europe/Amsterdam").hours_in_day() - 25 + >>> ZonedDateTime(2020, 8, 15, tz="Europe/London").day_length() + TimeDelta(24:00:00) + >>> ZonedDateTime(2023, 10, 29, tz="Europe/Amsterdam").day_length() + TimeDelta(25:00:00) """ midnight = _datetime.combine( self._py_dt.date(), _time(), self._py_dt.tzinfo ) next_midnight = midnight + _timedelta(days=1) - return ( + return TimeDelta.from_py_timedelta( next_midnight.astimezone(_UTC) - midnight.astimezone(_UTC) - ) / _timedelta(hours=1) + ) def start_of_day(self) -> ZonedDateTime: """The start of the current calendar day. diff --git a/src/date.rs b/src/date.rs index 2acceb4a..6281e5a2 100644 --- a/src/date.rs +++ b/src/date.rs @@ -27,35 +27,7 @@ impl Date { // Since the data already fits within an i32 // we don't need to do any extra hashing. It may be counterintuitive, // but this is also what `int` does: `hash(6) == 6`. - mem::transmute::<_, i32>(self) - } - - pub(crate) const fn increment(mut self) -> Self { - if self.day < days_in_month(self.year, self.month) { - self.day += 1 - } else if self.month < 12 { - self.day = 1; - self.month += 1; - } else { - self.year += 1; - self.month = 1; - self.day = 1; - } - self - } - - pub(crate) const fn decrement(mut self) -> Self { - if self.day > 1 { - self.day -= 1; - } else if self.month > 1 { - self.month -= 1; - self.day = days_in_month(self.year, self.month); - } else { - self.day = 31; - self.month = 12; - self.year -= 1; - } - self + mem::transmute(self) } pub(crate) const fn ord(self) -> u32 { @@ -199,6 +171,36 @@ impl Date { *s = &s[10..]; result } + + // Faster methods for small adjustments. + // OPTIMIZE: actually determine if these are worth it + pub(crate) const fn increment(mut self) -> Self { + if self.day < days_in_month(self.year, self.month) { + self.day += 1 + } else if self.month < 12 { + self.day = 1; + self.month += 1; + } else { + self.year += 1; + self.month = 1; + self.day = 1; + } + self + } + + pub(crate) const fn decrement(mut self) -> Self { + if self.day > 1 { + self.day -= 1; + } else if self.month > 1 { + self.month -= 1; + self.day = days_in_month(self.year, self.month); + } else { + self.day = 31; + self.month = 12; + self.year -= 1; + } + self + } } impl PyWrapped for Date {} diff --git a/src/docstrings.rs b/src/docstrings.rs index f99a1af9..cf780c85 100644 --- a/src/docstrings.rs +++ b/src/docstrings.rs @@ -1634,6 +1634,20 @@ specify how to handle such a situation using the ``disambiguate`` argument. See `the documentation `_ for more information. "; +pub(crate) const ZONEDDATETIME_DAY_LENGTH: &CStr = c"\ +day_length($self) +-- + +The duration between the start of the current day and the next. +This is usually 24 hours, but may be different due to timezone transitions. + +Example +------- +>>> ZonedDateTime(2020, 8, 15, tz=\"Europe/London\").day_length() +TimeDelta(24:00:00) +>>> ZonedDateTime(2023, 10, 29, tz=\"Europe/Amsterdam\").day_length() +TimeDelta(25:00:00) +"; pub(crate) const ZONEDDATETIME_FORMAT_COMMON_ISO: &CStr = c"\ format_common_iso($self) -- @@ -1691,20 +1705,6 @@ Create an instance from a UNIX timestamp (in nanoseconds). The inverse of the ``timestamp_nanos()`` method. "; -pub(crate) const ZONEDDATETIME_HOURS_IN_DAY: &CStr = c"\ -hours_in_day($self) --- - -The number of hours in the day, accounting for timezone transitions, -e.g. during a DST transition. - -Example -------- ->>> ZonedDateTime(2020, 8, 15, tz=\"Europe/London\").hours_in_day() -24 ->>> ZonedDateTime(2023, 10, 29, tz=\"Europe/Amsterdam\").hours_in_day() -25 -"; pub(crate) const ZONEDDATETIME_IS_AMBIGUOUS: &CStr = c"\ is_ambiguous($self) -- diff --git a/src/time.rs b/src/time.rs index d849bd8c..57558d69 100644 --- a/src/time.rs +++ b/src/time.rs @@ -194,15 +194,7 @@ pub(crate) const MIDNIGHT: Time = Time { }; pub(crate) const SINGLETONS: &[(&CStr, Time); 3] = &[ - ( - c"MIDNIGHT", - Time { - hour: 0, - minute: 0, - second: 0, - nanos: 0, - }, - ), + (c"MIDNIGHT", MIDNIGHT), ( c"NOON", Time { diff --git a/src/zoned_datetime.rs b/src/zoned_datetime.rs index 3118e04d..c8b5604d 100644 --- a/src/zoned_datetime.rs +++ b/src/zoned_datetime.rs @@ -1348,12 +1348,13 @@ unsafe fn start_of_day(slf: *mut PyObject, _: *mut PyObject) -> PyReturn { .to_obj(Py_TYPE(slf)) } -unsafe fn hours_in_day(slf: *mut PyObject, _: *mut PyObject) -> PyReturn { +unsafe fn day_length(slf: *mut PyObject, _: *mut PyObject) -> PyReturn { let ZonedDateTime { date, zoneinfo, .. } = ZonedDateTime::extract(slf); let &State { py_api, exc_repeated, exc_skipped, + time_delta_type, .. } = State::for_obj(slf); let start_of_day = ZonedDateTime::resolve_using_disambiguate( @@ -1376,8 +1377,8 @@ unsafe fn hours_in_day(slf: *mut PyObject, _: *mut PyObject) -> PyReturn { exc_skipped, )? .instant(); - ((start_of_next_day.total_nanos() - start_of_day.total_nanos()) as f64 / 3_600_000_000_000.0) - .to_py() + TimeDelta::from_nanos_unchecked(start_of_next_day.total_nanos() - start_of_day.total_nanos()) + .to_obj(time_delta_type) } static mut METHODS: &[PyMethodDef] = &[ @@ -1431,7 +1432,7 @@ static mut METHODS: &[PyMethodDef] = &[ method_kwargs!(subtract, doc::ZONEDDATETIME_SUBTRACT), method!(difference, doc::KNOWSINSTANT_DIFFERENCE, METH_O), method!(start_of_day, doc::ZONEDDATETIME_START_OF_DAY), - method!(hours_in_day, doc::ZONEDDATETIME_HOURS_IN_DAY), + method!(day_length, doc::ZONEDDATETIME_DAY_LENGTH), PyMethodDef::zeroed(), ]; diff --git a/tests/test_zoned_datetime.py b/tests/test_zoned_datetime.py index 12448560..2fd677c2 100644 --- a/tests/test_zoned_datetime.py +++ b/tests/test_zoned_datetime.py @@ -12,7 +12,6 @@ import pytest from hypothesis import given from hypothesis.strategies import text -from pytest import approx from whenever import ( Date, @@ -24,6 +23,7 @@ SkippedTime, SystemDateTime, Time, + TimeDelta, ZonedDateTime, days, hours, @@ -577,42 +577,51 @@ def test_is_ambiguous(): "d, expect", [ # no special day - (ZonedDateTime(2020, 8, 15, 12, 8, 30, tz="Europe/Amsterdam"), 24), - (ZonedDateTime(1832, 12, 15, 12, 1, 30, tz="UTC"), 24), + ( + ZonedDateTime(2020, 8, 15, 12, 8, 30, tz="Europe/Amsterdam"), + hours(24), + ), + (ZonedDateTime(1832, 12, 15, 12, 1, 30, tz="UTC"), hours(24)), # Longer day - (ZonedDateTime(2023, 10, 29, 12, 8, 30, tz="Europe/Amsterdam"), 25), - (ZonedDateTime(2023, 10, 29, tz="Europe/Amsterdam"), 25), + ( + ZonedDateTime(2023, 10, 29, 12, 8, 30, tz="Europe/Amsterdam"), + hours(25), + ), + (ZonedDateTime(2023, 10, 29, tz="Europe/Amsterdam"), hours(25)), ( ZonedDateTime(2023, 10, 30, tz="Europe/Amsterdam").subtract( nanoseconds=1 ), - 25, + hours(25), ), # Shorter day - (ZonedDateTime(2023, 3, 26, 12, 8, 30, tz="Europe/Amsterdam"), 23), - (ZonedDateTime(2023, 3, 26, tz="Europe/Amsterdam"), 23), + ( + ZonedDateTime(2023, 3, 26, 12, 8, 30, tz="Europe/Amsterdam"), + hours(23), + ), + (ZonedDateTime(2023, 3, 26, tz="Europe/Amsterdam"), hours(23)), ( ZonedDateTime(2023, 3, 27, tz="Europe/Amsterdam").subtract( nanoseconds=1 ), - 23, + hours(23), ), # non-hour DST change - (ZonedDateTime(2024, 10, 6, 1, tz="Australia/Lord_Howe"), 23.5), - (ZonedDateTime(2024, 4, 7, 1, tz="Australia/Lord_Howe"), 24.5), + (ZonedDateTime(2024, 10, 6, 1, tz="Australia/Lord_Howe"), hours(23.5)), + (ZonedDateTime(2024, 4, 7, 1, tz="Australia/Lord_Howe"), hours(24.5)), # Non-regular transition ( ZonedDateTime(1894, 6, 1, 1, tz="Europe/Zurich"), - approx(23.49611111), + TimeDelta(hours=24, minutes=-30, seconds=-14), ), # DST starts at midnight - (ZonedDateTime(2016, 2, 20, tz="America/Sao_Paulo"), 25), - (ZonedDateTime(2016, 2, 21, tz="America/Sao_Paulo"), 24), - (ZonedDateTime(2016, 10, 16, tz="America/Sao_Paulo"), 23), - (ZonedDateTime(2016, 10, 17, tz="America/Sao_Paulo"), 24), + (ZonedDateTime(2016, 2, 20, tz="America/Sao_Paulo"), hours(25)), + (ZonedDateTime(2016, 2, 21, tz="America/Sao_Paulo"), hours(24)), + (ZonedDateTime(2016, 10, 16, tz="America/Sao_Paulo"), hours(23)), + (ZonedDateTime(2016, 10, 17, tz="America/Sao_Paulo"), hours(24)), # Samoa skipped a day - (ZonedDateTime(2011, 12, 31, 21, tz="Pacific/Apia"), 24), - (ZonedDateTime(2011, 12, 29, 21, tz="Pacific/Apia"), 24), + (ZonedDateTime(2011, 12, 31, 21, tz="Pacific/Apia"), hours(24)), + (ZonedDateTime(2011, 12, 29, 21, tz="Pacific/Apia"), hours(24)), # A day that starts twice ( ZonedDateTime( @@ -624,7 +633,7 @@ def test_is_ambiguous(): disambiguate="later", tz="America/Sao_Paulo", ), - 25, + hours(25), ), ( ZonedDateTime( @@ -636,12 +645,12 @@ def test_is_ambiguous(): disambiguate="earlier", tz="America/Sao_Paulo", ), - 25, + hours(25), ), ], ) -def test_hours_in_day(d, expect): - assert d.hours_in_day() == expect +def test_day_length(d, expect): + assert d.day_length() == expect @pytest.mark.parametrize(