Skip to content

Commit

Permalink
Generalize hours_in_day() to day_length()
Browse files Browse the repository at this point in the history
  • Loading branch information
ariebovenberg committed Jan 30, 2025
1 parent c7a9ca2 commit f9c903c
Show file tree
Hide file tree
Showing 9 changed files with 99 additions and 93 deletions.
4 changes: 2 additions & 2 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -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

Expand Down
8 changes: 4 additions & 4 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ Concrete classes
:members:
tz,
is_ambiguous,
start_of_day,
hours_in_day,
:member-order: bysource
:show-inheritance:

Expand Down
18 changes: 9 additions & 9 deletions pysrc/whenever/_pywhenever.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
60 changes: 31 additions & 29 deletions src/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {}
Expand Down
28 changes: 14 additions & 14 deletions src/docstrings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1634,6 +1634,20 @@ specify how to handle such a situation using the ``disambiguate`` argument.
See `the documentation <https://whenever.rtfd.io/en/latest/overview.html#arithmetic>`_
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)
--
Expand Down Expand Up @@ -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)
--
Expand Down
10 changes: 1 addition & 9 deletions src/time.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 5 additions & 4 deletions src/zoned_datetime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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] = &[
Expand Down Expand Up @@ -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(),
];

Expand Down
53 changes: 31 additions & 22 deletions tests/test_zoned_datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
import pytest
from hypothesis import given
from hypothesis.strategies import text
from pytest import approx

from whenever import (
Date,
Expand All @@ -24,6 +23,7 @@
SkippedTime,
SystemDateTime,
Time,
TimeDelta,
ZonedDateTime,
days,
hours,
Expand Down Expand Up @@ -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(
Expand All @@ -624,7 +633,7 @@ def test_is_ambiguous():
disambiguate="later",
tz="America/Sao_Paulo",
),
25,
hours(25),
),
(
ZonedDateTime(
Expand All @@ -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(
Expand Down

0 comments on commit f9c903c

Please sign in to comment.