diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8eec5ed..d38eca5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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) ------------------- diff --git a/pysrc/whenever/__init__.pyi b/pysrc/whenever/__init__.pyi index 26cecdd..9e9472d 100644 --- a/pysrc/whenever/__init__.pyi +++ b/pysrc/whenever/__init__.pyi @@ -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: ... @@ -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 @@ -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: ... @@ -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, @@ -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: ... diff --git a/pysrc/whenever/_pywhenever.py b/pysrc/whenever/_pywhenever.py index 35d7912..1eac1a0 100644 --- a/pysrc/whenever/_pywhenever.py +++ b/pysrc/whenever/_pywhenever.py @@ -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}") diff --git a/src/docstrings.rs b/src/docstrings.rs index a33e954..f99a1af 100644 --- a/src/docstrings.rs +++ b/src/docstrings.rs @@ -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) @@ -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) -- @@ -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) -- diff --git a/src/time.rs b/src/time.rs index f663033..d849bd8 100644 --- a/src/time.rs +++ b/src/time.rs @@ -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", diff --git a/src/zoned_datetime.rs b/src/zoned_datetime.rs index 1caebeb..3118e04 100644 --- a/src/zoned_datetime.rs +++ b/src/zoned_datetime.rs @@ -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, }; @@ -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), @@ -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(), ]; diff --git a/tests/test_instant.py b/tests/test_instant.py index e0606df..717ffbd 100644 --- a/tests/test_instant.py +++ b/tests/test_instant.py @@ -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)): diff --git a/tests/test_local_datetime.py b/tests/test_local_datetime.py index 77bc8ee..8cf0762 100644 --- a/tests/test_local_datetime.py +++ b/tests/test_local_datetime.py @@ -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) @@ -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): @@ -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): @@ -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() @@ -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") ) diff --git a/tests/test_system_datetime.py b/tests/test_system_datetime.py index 7abf279..5ab3ff6 100644 --- a/tests/test_system_datetime.py +++ b/tests/test_system_datetime.py @@ -53,12 +53,8 @@ def test_basic(self): assert d.offset == hours(2) def test_optionality(self): - assert ( - SystemDateTime(2020, 8, 15, 12) - == SystemDateTime(2020, 8, 15, 12, 0) - == SystemDateTime(2020, 8, 15, 12, 0, 0) - == SystemDateTime(2020, 8, 15, 12, 0, 0, nanosecond=0) - == SystemDateTime( + assert SystemDateTime(2020, 8, 15, 12).exact_eq( + SystemDateTime( 2020, 8, 15, 12, 0, 0, nanosecond=0, disambiguate="raise" ) ) @@ -136,14 +132,16 @@ class TestInstant: @system_tz_ams() def test_common_time(self): d = SystemDateTime(2020, 8, 15, 11) - assert d.instant() == Instant.from_utc(2020, 8, 15, 9) + assert d.instant().exact_eq(Instant.from_utc(2020, 8, 15, 9)) @system_tz_ams() def test_amibiguous_time(self): d = SystemDateTime(2023, 10, 29, 2, 15, disambiguate="earlier") - assert d.instant() == Instant.from_utc(2023, 10, 29, 0, 15) - assert d.replace(disambiguate="later").instant() == Instant.from_utc( - 2023, 10, 29, 1, 15 + assert d.instant().exact_eq(Instant.from_utc(2023, 10, 29, 0, 15)) + assert ( + d.replace(disambiguate="later") + .instant() + .exact_eq(Instant.from_utc(2023, 10, 29, 1, 15)) ) @@ -167,12 +165,14 @@ def test_to_tz(): .to_tz("America/New_York") .exact_eq(nyc.replace(hour=21, disambiguate="raise")) ) - assert nyc.to_system_tz() == ams - assert nyc.replace( - hour=21, disambiguate="raise" - ).to_system_tz() == ams.replace(disambiguate="later") + assert nyc.to_system_tz().exact_eq(ams) + assert ( + nyc.replace(hour=21, disambiguate="raise") + .to_system_tz() + .exact_eq(ams.replace(disambiguate="later")) + ) # disambiguation doesn't affect NYC time because there's no ambiguity - assert nyc.replace(disambiguate="later").to_system_tz() == ams + assert nyc.replace(disambiguate="later").to_system_tz().exact_eq(ams) try: d_min = Instant.MIN.to_system_tz() diff --git a/tests/test_zoned_datetime.py b/tests/test_zoned_datetime.py index 75a4ac6..1244856 100644 --- a/tests/test_zoned_datetime.py +++ b/tests/test_zoned_datetime.py @@ -68,8 +68,8 @@ def test_repeated_time(self): tz="Europe/Amsterdam", ) - assert ZonedDateTime(**kwargs) == ZonedDateTime( - **kwargs, disambiguate="compatible" + assert ZonedDateTime(**kwargs).exact_eq( + ZonedDateTime(**kwargs, disambiguate="compatible") ) with pytest.raises( @@ -102,12 +102,8 @@ def test_invalid_zone(self): def test_optionality(self): tz = "America/New_York" - assert ( - ZonedDateTime(2020, 8, 15, 12, tz=tz) - == ZonedDateTime(2020, 8, 15, 12, 0, tz=tz) - == ZonedDateTime(2020, 8, 15, 12, 0, 0, tz=tz) - == ZonedDateTime(2020, 8, 15, 12, 0, 0, nanosecond=0, tz=tz) - == ZonedDateTime( + assert ZonedDateTime(2020, 8, 15, 12, tz=tz).exact_eq( + ZonedDateTime( 2020, 8, 15, @@ -155,8 +151,8 @@ def test_skipped(self): tz="Europe/Amsterdam", ) - assert ZonedDateTime(**kwargs) == ZonedDateTime( - **kwargs, disambiguate="compatible" + assert ZonedDateTime(**kwargs).exact_eq( + ZonedDateTime(**kwargs, disambiguate="compatible") ) with pytest.raises( @@ -733,9 +729,11 @@ def test_start_of_day(d, expect): def test_instant(): - assert ZonedDateTime( - 2020, 8, 15, 12, 8, 30, tz="Europe/Amsterdam" - ).instant() == Instant.from_utc(2020, 8, 15, 10, 8, 30) + assert ( + ZonedDateTime(2020, 8, 15, 12, 8, 30, tz="Europe/Amsterdam") + .instant() + .exact_eq(Instant.from_utc(2020, 8, 15, 10, 8, 30)) + ) d = ZonedDateTime( 2023, 10, @@ -746,10 +744,21 @@ def test_instant(): tz="Europe/Amsterdam", disambiguate="earlier", ) - assert d.instant() == Instant.from_utc(2023, 10, 29, 0, 15, 30) - assert ZonedDateTime( - 2023, 10, 29, 2, 15, 30, tz="Europe/Amsterdam", disambiguate="later" - ).instant() == Instant.from_utc(2023, 10, 29, 1, 15, 30) + assert d.instant().exact_eq(Instant.from_utc(2023, 10, 29, 0, 15, 30)) + assert ( + ZonedDateTime( + 2023, + 10, + 29, + 2, + 15, + 30, + tz="Europe/Amsterdam", + disambiguate="later", + ) + .instant() + .exact_eq(Instant.from_utc(2023, 10, 29, 1, 15, 30)) + ) def test_to_tz(): @@ -2093,17 +2102,17 @@ def test_zero(self): assert d.add().exact_eq(d) # same with operators - assert d + days(0) == d - assert d + weeks(0) == d - assert d + years(0) == d + assert (d + days(0)).exact_eq(d) + assert (d + weeks(0)).exact_eq(d) + assert (d + years(0)).exact_eq(d) # same with subtraction assert d.subtract(days=0, disambiguate="raise").exact_eq(d) assert d.subtract(days=0).exact_eq(d) - assert d - days(0) == d - assert d - weeks(0) == d - assert d - years(0) == d + assert (d - days(0)).exact_eq(d) + assert (d - weeks(0)).exact_eq(d) + assert (d - years(0)).exact_eq(d) def test_simple_date(self): d = ZonedDateTime( @@ -2138,17 +2147,17 @@ def test_simple_date(self): d.add(years=1, weeks=2, hours=2) ) # same with operators - assert d + (years(1) + weeks(2) + days(-2)) == d.add( - years=1, weeks=2, days=-2 + assert (d + (years(1) + weeks(2) + days(-2))).exact_eq( + d.add(years=1, weeks=2, days=-2) ) - assert d + (years(1) + weeks(2) + hours(2)) == d.add( - years=1, weeks=2, hours=2 + assert (d + (years(1) + weeks(2) + hours(2))).exact_eq( + d.add(years=1, weeks=2, hours=2) ) - assert d - (years(1) + weeks(2) + days(-2)) == d.subtract( - years=1, weeks=2, days=-2 + assert (d - (years(1) + weeks(2) + days(-2))).exact_eq( + d.subtract(years=1, weeks=2, days=-2) ) - assert d - (years(1) + weeks(2) + hours(2)) == d.subtract( - years=1, weeks=2, hours=2 + assert (d - (years(1) + weeks(2) + hours(2))).exact_eq( + d.subtract(years=1, weeks=2, hours=2) ) def test_ambiguity(self): @@ -2173,7 +2182,7 @@ def test_ambiguity(self): d.replace(year=2024, day=27, disambiguate="earlier") ) # check operators too - assert d + years(1) - days(2) == d.add(years=1, days=-2) + assert (d + years(1) - days(2)).exact_eq(d.add(years=1, days=-2)) # transition to a gap assert d.add(months=5, days=2, disambiguate="compatible").exact_eq(