Skip to content

Commit

Permalink
add hours_in_day and start_of_day helpers
Browse files Browse the repository at this point in the history
  • Loading branch information
ariebovenberg committed Jan 29, 2025
1 parent 5c1dc7b commit c7a9ca2
Show file tree
Hide file tree
Showing 10 changed files with 221 additions and 96 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
🚀 Changelog
============

0.6.17 (2024-12-??)
0.6.17 (2025-01-29)
-------------------

- Added ``hours_in_day()`` 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

0.6.16 (2024-12-22)
-------------------
Expand Down
32 changes: 17 additions & 15 deletions pysrc/whenever/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ class Time:
def second(self) -> int: ...
@property
def nanosecond(self) -> int: ...
def on(self, d: Date) -> LocalDateTime: ...
def on(self, d: Date, /) -> LocalDateTime: ...
def py_time(self) -> _time: ...
@classmethod
def from_py_time(cls, t: _time, /) -> Time: ...
Expand Down Expand Up @@ -659,6 +659,8 @@ class ZonedDateTime(_KnowsInstantAndLocal):
*,
disambiguate: Literal["compatible", "raise", "earlier", "later"] = ...,
) -> ZonedDateTime: ...
def hours_in_day(self) -> float: ...
def start_of_day(self) -> ZonedDateTime: ...
# FUTURE: disable date components in strict stubs version
def __add__(self, delta: Delta) -> ZonedDateTime: ...
@overload
Expand Down Expand Up @@ -828,12 +830,12 @@ class LocalDateTime(_KnowsLocal):
tz: str,
/,
*,
disambiguate: Literal["compatible", "raise", "earlier", "later"],
disambiguate: Literal["compatible", "raise", "earlier", "later"] = ...,
) -> ZonedDateTime: ...
def assume_system_tz(
self,
*,
disambiguate: Literal["compatible", "raise", "earlier", "later"],
disambiguate: Literal["compatible", "raise", "earlier", "later"] = ...,
) -> SystemDateTime: ...
@classmethod
def from_py_datetime(cls, d: _datetime, /) -> LocalDateTime: ...
Expand All @@ -854,8 +856,8 @@ class LocalDateTime(_KnowsLocal):
second: int = ...,
nanosecond: int = ...,
) -> LocalDateTime: ...
def replace_date(self, d: Date) -> LocalDateTime: ...
def replace_time(self, t: Time) -> LocalDateTime: ...
def replace_date(self, d: Date, /) -> LocalDateTime: ...
def replace_time(self, t: Time, /) -> LocalDateTime: ...
@overload
def add(
self,
Expand Down Expand Up @@ -947,16 +949,16 @@ FRIDAY = Weekday.FRIDAY
SATURDAY = Weekday.SATURDAY
SUNDAY = Weekday.SUNDAY

def years(i: int) -> DateDelta: ...
def months(i: int) -> DateDelta: ...
def weeks(i: int) -> DateDelta: ...
def days(i: int) -> DateDelta: ...
def hours(i: float) -> TimeDelta: ...
def minutes(i: float) -> TimeDelta: ...
def seconds(i: float) -> TimeDelta: ...
def milliseconds(i: float) -> TimeDelta: ...
def microseconds(i: float) -> TimeDelta: ...
def nanoseconds(i: int) -> TimeDelta: ...
def years(i: int, /) -> DateDelta: ...
def months(i: int, /) -> DateDelta: ...
def weeks(i: int, /) -> DateDelta: ...
def days(i: int, /) -> DateDelta: ...
def hours(i: float, /) -> TimeDelta: ...
def minutes(i: float, /) -> TimeDelta: ...
def seconds(i: float, /) -> TimeDelta: ...
def milliseconds(i: float, /) -> TimeDelta: ...
def microseconds(i: float, /) -> TimeDelta: ...
def nanoseconds(i: int, /) -> TimeDelta: ...

class _TimePatch:
def shift(self, *args: Any, **kwargs: Any) -> None: ...
Expand Down
4 changes: 2 additions & 2 deletions pysrc/whenever/_pywhenever.py
Original file line number Diff line number Diff line change
Expand Up @@ -612,8 +612,8 @@ def parse_common_iso(cls, s: str, /) -> YearMonth:
Example
-------
>>> YearMonth.parse_common_iso("2021-01-02")
YearMonth(2021-01-02)
>>> YearMonth.parse_common_iso("2021-01")
YearMonth(2021-01)
"""
if not _match_yearmonth(s):
raise ValueError(f"Invalid format: {s!r}")
Expand Down
27 changes: 25 additions & 2 deletions src/docstrings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1603,8 +1603,8 @@ Inverse of :meth:`format_common_iso`
Example
-------
>>> YearMonth.parse_common_iso(\"2021-01-02\")
YearMonth(2021-01-02)
>>> YearMonth.parse_common_iso(\"2021-01\")
YearMonth(2021-01)
";
pub(crate) const YEARMONTH_REPLACE: &CStr = c"\
replace($self, /, *, year=None, month=None)
Expand Down Expand Up @@ -1691,6 +1691,20 @@ 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 Expand Up @@ -1761,6 +1775,15 @@ Construct a new instance with the time replaced.
See the ``replace()`` method for more information.
";
pub(crate) const ZONEDDATETIME_START_OF_DAY: &CStr = c"\
start_of_day($self)
--
The start of the current calendar day.
This is almost always at midnight the same day, but may be different
for timezones which transition at—and thus skip over—midnight.
";
pub(crate) const ZONEDDATETIME_SUBTRACT: &CStr = c"\
subtract($self, delta=None, /, *, years=0, months=0, days=0, hours=0, minutes=0, seconds=0, milliseconds=0, microseconds=0, nanoseconds=0, disambiguate=None)
--
Expand Down
7 changes: 7 additions & 0 deletions src/time.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,13 @@ impl Display for Time {
}
}

pub(crate) const MIDNIGHT: Time = Time {
hour: 0,
minute: 0,
second: 0,
nanos: 0,
};

pub(crate) const SINGLETONS: &[(&CStr, Time); 3] = &[
(
c"MIDNIGHT",
Expand Down
56 changes: 55 additions & 1 deletion src/zoned_datetime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use crate::{
instant::{Instant, MAX_INSTANT, MIN_INSTANT},
local_datetime::DateTime,
offset_datetime::{self, OffsetDateTime},
time::Time,
time::{Time, MIDNIGHT},
time_delta::{self, TimeDelta},
State,
};
Expand Down Expand Up @@ -1328,6 +1328,58 @@ unsafe fn difference(obj_a: *mut PyObject, obj_b: *mut PyObject) -> PyReturn {
.to_obj(state.time_delta_type)
}

unsafe fn start_of_day(slf: *mut PyObject, _: *mut PyObject) -> PyReturn {
let ZonedDateTime { date, zoneinfo, .. } = ZonedDateTime::extract(slf);
let &State {
py_api,
exc_repeated,
exc_skipped,
..
} = State::for_obj(slf);
ZonedDateTime::resolve_using_disambiguate(
py_api,
date,
MIDNIGHT,
zoneinfo,
Disambiguate::Compatible,
exc_repeated,
exc_skipped,
)?
.to_obj(Py_TYPE(slf))
}

unsafe fn hours_in_day(slf: *mut PyObject, _: *mut PyObject) -> PyReturn {
let ZonedDateTime { date, zoneinfo, .. } = ZonedDateTime::extract(slf);
let &State {
py_api,
exc_repeated,
exc_skipped,
..
} = State::for_obj(slf);
let start_of_day = ZonedDateTime::resolve_using_disambiguate(
py_api,
date,
MIDNIGHT,
zoneinfo,
Disambiguate::Compatible,
exc_repeated,
exc_skipped,
)?
.instant();
let start_of_next_day = ZonedDateTime::resolve_using_disambiguate(
py_api,
date.increment(),
MIDNIGHT,
zoneinfo,
Disambiguate::Compatible,
exc_repeated,
exc_skipped,
)?
.instant();
((start_of_next_day.total_nanos() - start_of_day.total_nanos()) as f64 / 3_600_000_000_000.0)
.to_py()
}

static mut METHODS: &[PyMethodDef] = &[
method!(identity2 named "__copy__", c""),
method!(identity2 named "__deepcopy__", c"", METH_O),
Expand Down Expand Up @@ -1378,6 +1430,8 @@ static mut METHODS: &[PyMethodDef] = &[
method_kwargs!(add, doc::ZONEDDATETIME_ADD),
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),
PyMethodDef::zeroed(),
];

Expand Down
6 changes: 4 additions & 2 deletions tests/test_instant.py
Original file line number Diff line number Diff line change
Expand Up @@ -826,8 +826,10 @@ def test_to_system_tz():
assert d.to_system_tz().exact_eq(
SystemDateTime(2022, 11, 6, 1, disambiguate="earlier")
)
assert Instant.from_utc(2022, 11, 6, 6).to_system_tz() == SystemDateTime(
2022, 11, 6, 1, disambiguate="later"
assert (
Instant.from_utc(2022, 11, 6, 6)
.to_system_tz()
.exact_eq(SystemDateTime(2022, 11, 6, 1, disambiguate="later"))
)

with pytest.raises((ValueError, OverflowError)):
Expand Down
79 changes: 53 additions & 26 deletions tests/test_local_datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,12 @@ def test_assume_fixed_offset():
class TestAssumeTz:
def test_typical(self):
d = LocalDateTime(2020, 8, 15, 23)
assert d.assume_tz(
"Asia/Tokyo", disambiguate="raise"
) == ZonedDateTime(2020, 8, 15, 23, tz="Asia/Tokyo")
assert d.assume_tz("Asia/Tokyo", disambiguate="raise").exact_eq(
ZonedDateTime(2020, 8, 15, 23, tz="Asia/Tokyo")
)
assert d.assume_tz("Asia/Tokyo").exact_eq(
ZonedDateTime(2020, 8, 15, 23, tz="Asia/Tokyo")
)

def test_ambiguous(self):
d = LocalDateTime(2023, 10, 29, 2, 15)
Expand All @@ -94,13 +97,27 @@ def test_ambiguous(self):

assert d.assume_tz(
"Europe/Amsterdam", disambiguate="earlier"
) == ZonedDateTime(
2023, 10, 29, 2, 15, tz="Europe/Amsterdam", disambiguate="earlier"
).exact_eq(
ZonedDateTime(
2023,
10,
29,
2,
15,
tz="Europe/Amsterdam",
disambiguate="earlier",
)
)
assert d.assume_tz(
"Europe/Amsterdam", disambiguate="later"
) == ZonedDateTime(
2023, 10, 29, 2, 15, tz="Europe/Amsterdam", disambiguate="later"
assert d.assume_tz("Europe/Amsterdam", disambiguate="later").exact_eq(
ZonedDateTime(
2023,
10,
29,
2,
15,
tz="Europe/Amsterdam",
disambiguate="later",
)
)

def test_nonexistent(self):
Expand All @@ -111,17 +128,27 @@ def test_nonexistent(self):

assert d.assume_tz(
"Europe/Amsterdam", disambiguate="earlier"
) == ZonedDateTime(
2023, 3, 26, 2, 15, tz="Europe/Amsterdam", disambiguate="earlier"
).exact_eq(
ZonedDateTime(
2023,
3,
26,
2,
15,
tz="Europe/Amsterdam",
disambiguate="earlier",
)
)


class TestAssumeSystemTz:
@system_tz_ams()
def test_typical(self):
assert LocalDateTime(2020, 8, 15, 23).assume_system_tz(
disambiguate="raise"
) == SystemDateTime(2020, 8, 15, 23)
assert (
LocalDateTime(2020, 8, 15, 23)
.assume_system_tz(disambiguate="raise")
.exact_eq(SystemDateTime(2020, 8, 15, 23))
)

@system_tz_ams()
def test_ambiguous(self):
Expand All @@ -130,14 +157,14 @@ def test_ambiguous(self):
with pytest.raises(RepeatedTime, match="02:15.*system"):
d.assume_system_tz(disambiguate="raise")

assert d.assume_system_tz(disambiguate="earlier") == SystemDateTime(
2023, 10, 29, 2, 15, disambiguate="earlier"
assert d.assume_system_tz(disambiguate="earlier").exact_eq(
SystemDateTime(2023, 10, 29, 2, 15, disambiguate="earlier")
)
assert d.assume_system_tz(disambiguate="compatible") == SystemDateTime(
2023, 10, 29, 2, 15, disambiguate="earlier"
assert d.assume_system_tz(disambiguate="compatible").exact_eq(
SystemDateTime(2023, 10, 29, 2, 15, disambiguate="earlier")
)
assert d.assume_system_tz(disambiguate="later") == SystemDateTime(
2023, 10, 29, 2, 15, disambiguate="later"
assert d.assume_system_tz(disambiguate="later").exact_eq(
SystemDateTime(2023, 10, 29, 2, 15, disambiguate="later")
)

@system_tz_ams()
Expand All @@ -147,14 +174,14 @@ def test_nonexistent(self):
with pytest.raises(SkippedTime, match="02:15.*system"):
d.assume_system_tz(disambiguate="raise")

assert d.assume_system_tz(disambiguate="earlier") == SystemDateTime(
2023, 3, 26, 2, 15, disambiguate="earlier"
assert d.assume_system_tz(disambiguate="earlier").exact_eq(
SystemDateTime(2023, 3, 26, 2, 15, disambiguate="earlier")
)
assert d.assume_system_tz(disambiguate="later") == SystemDateTime(
2023, 3, 26, 2, 15, disambiguate="later"
assert d.assume_system_tz(disambiguate="later").exact_eq(
SystemDateTime(2023, 3, 26, 2, 15, disambiguate="later")
)
assert d.assume_system_tz(disambiguate="compatible") == SystemDateTime(
2023, 3, 26, 2, 15, disambiguate="compatible"
assert d.assume_system_tz(disambiguate="compatible").exact_eq(
SystemDateTime(2023, 3, 26, 2, 15, disambiguate="compatible")
)


Expand Down
Loading

0 comments on commit c7a9ca2

Please sign in to comment.