diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 405e32d..d38eca5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,13 @@ 🚀 Changelog ============ +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/Cargo.lock b/Cargo.lock index 2d010cb..e211a5f 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/README.md b/README.md index 6ca2c0b..f5484ac 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ There’s no way to be sure... ✨ Until now! ✨ *Whenever* helps you write **correct** and **type checked** datetime code, -using **well-established concepts** from modern libraries in other languages. +using **well-established concepts** from [modern libraries](#acknowledgements) in other languages. It's also **way faster** than other third-party libraries—and usually the standard library as well. If performance isn't your top priority, a **pure Python** version is available as well. @@ -214,10 +214,10 @@ For more details, see the licenses included in the distribution. ## Acknowledgements -This project is inspired by—and borrows concepts from—the following projects. Check them out! +This project is inspired by—and borrows most concepts from—the following projects. Check them out! -- [Noda Time](https://nodatime.org/) and [Joda Time](https://www.joda.org/joda-time/) - [Temporal](https://tc39.es/proposal-temporal/docs/) +- [Noda Time](https://nodatime.org/) and [Joda Time](https://www.joda.org/joda-time/) The benchmark comparison graph is based on the one from the [Ruff](https://github.com/astral-sh/ruff) project. For timezone data, **Whenever** uses Python's own `zoneinfo` module. diff --git a/docs/api.rst b/docs/api.rst index ed78261..dc6eafb 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/pyproject.toml b/pyproject.toml index b5cc756..171e1ed 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.16" +version = "0.6.17" description = "Modern datetime library for Python" requires-python = ">=3.9" classifiers = [ 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 58114c5..1eac1a0 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.16" +__version__ = "0.6.17" import enum import re @@ -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}") @@ -4310,6 +4310,38 @@ 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. + + Example + ------- + >>> ZonedDateTime(2020, 8, 15, tz="Europe/London").hours_in_day() + 24 + >>> ZonedDateTime(2023, 10, 29, tz="Europe/Amsterdam").hours_in_day() + 25 + """ + midnight = _datetime.combine( + self._py_dt.date(), _time(), self._py_dt.tzinfo + ) + next_midnight = midnight + _timedelta(days=1) + return ( + next_midnight.astimezone(_UTC) - midnight.astimezone(_UTC) + ) / _timedelta(hours=1) + + def start_of_day(self) -> ZonedDateTime: + """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. + """ + midnight = _datetime.combine( + self._py_dt.date(), _time(), self._py_dt.tzinfo + ) + return ZonedDateTime._from_py_unchecked( + midnight.astimezone(_UTC).astimezone(self._py_dt.tzinfo), 0 + ) + def __repr__(self) -> str: return f"ZonedDateTime({str(self).replace('T', ' ', 1)})" diff --git a/src/date.rs b/src/date.rs index 2acceb4..aabcb5a 100644 --- a/src/date.rs +++ b/src/date.rs @@ -27,7 +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) + mem::transmute(self) } pub(crate) const fn increment(mut self) -> Self { 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..57558d6 100644 --- a/src/time.rs +++ b/src/time.rs @@ -186,16 +186,15 @@ 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", - 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 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 b708031..1244856 100644 --- a/tests/test_zoned_datetime.py +++ b/tests/test_zoned_datetime.py @@ -12,6 +12,7 @@ import pytest from hypothesis import given from hypothesis.strategies import text +from pytest import approx from whenever import ( Date, @@ -67,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( @@ -101,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, @@ -154,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( @@ -564,12 +561,179 @@ def test_is_ambiguous(): tz="Europe/Amsterdam", disambiguate="earlier", ).is_ambiguous() + # skipped times are shifted into non-ambiguous times + assert not ZonedDateTime( + 2023, + 3, + 26, + 2, + 15, + 30, + tz="Europe/Amsterdam", + ).is_ambiguous() + + +@pytest.mark.parametrize( + "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), + # Longer day + (ZonedDateTime(2023, 10, 29, 12, 8, 30, tz="Europe/Amsterdam"), 25), + (ZonedDateTime(2023, 10, 29, tz="Europe/Amsterdam"), 25), + ( + ZonedDateTime(2023, 10, 30, tz="Europe/Amsterdam").subtract( + nanoseconds=1 + ), + 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, 27, tz="Europe/Amsterdam").subtract( + nanoseconds=1 + ), + 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), + # Non-regular transition + ( + ZonedDateTime(1894, 6, 1, 1, tz="Europe/Zurich"), + approx(23.49611111), + ), + # 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), + # Samoa skipped a day + (ZonedDateTime(2011, 12, 31, 21, tz="Pacific/Apia"), 24), + (ZonedDateTime(2011, 12, 29, 21, tz="Pacific/Apia"), 24), + # A day that starts twice + ( + ZonedDateTime( + 2016, + 2, + 20, + 23, + 45, + disambiguate="later", + tz="America/Sao_Paulo", + ), + 25, + ), + ( + ZonedDateTime( + 2016, + 2, + 20, + 23, + 45, + disambiguate="earlier", + tz="America/Sao_Paulo", + ), + 25, + ), + ], +) +def test_hours_in_day(d, expect): + assert d.hours_in_day() == expect + + +@pytest.mark.parametrize( + "d, expect", + [ + # no special day + ( + ZonedDateTime(2020, 8, 15, 12, 8, 30, tz="Europe/Amsterdam"), + ZonedDateTime(2020, 8, 15, tz="Europe/Amsterdam"), + ), + ( + ZonedDateTime(1832, 12, 15, 12, 1, 30, tz="UTC"), + ZonedDateTime(1832, 12, 15, tz="UTC"), + ), + # DST at non-midnight + ( + ZonedDateTime(2023, 10, 29, 12, 8, 30, tz="Europe/Amsterdam"), + ZonedDateTime(2023, 10, 29, tz="Europe/Amsterdam"), + ), + ( + ZonedDateTime(2023, 3, 26, 12, 8, 30, tz="Europe/Amsterdam"), + ZonedDateTime(2023, 3, 26, tz="Europe/Amsterdam"), + ), + ( + ZonedDateTime(2024, 4, 7, 1, tz="Australia/Lord_Howe"), + ZonedDateTime(2024, 4, 7, tz="Australia/Lord_Howe"), + ), + # Non-regular transition + ( + ZonedDateTime(1894, 6, 1, 1, tz="Europe/Zurich"), + ZonedDateTime(1894, 6, 1, 0, 30, 14, tz="Europe/Zurich"), + ), + # DST starts at midnight + ( + ZonedDateTime(2016, 2, 20, 8, tz="America/Sao_Paulo"), + ZonedDateTime(2016, 2, 20, tz="America/Sao_Paulo"), + ), + ( + ZonedDateTime(2016, 2, 21, 2, tz="America/Sao_Paulo"), + ZonedDateTime(2016, 2, 21, tz="America/Sao_Paulo"), + ), + ( + ZonedDateTime(2016, 10, 16, 15, tz="America/Sao_Paulo"), + ZonedDateTime(2016, 10, 16, 1, tz="America/Sao_Paulo"), + ), + ( + ZonedDateTime(2016, 10, 17, 19, tz="America/Sao_Paulo"), + ZonedDateTime(2016, 10, 17, tz="America/Sao_Paulo"), + ), + # Samoa skipped a day + ( + ZonedDateTime(2011, 12, 31, 21, tz="Pacific/Apia"), + ZonedDateTime(2011, 12, 31, tz="Pacific/Apia"), + ), + ( + ZonedDateTime(2011, 12, 29, 21, tz="Pacific/Apia"), + ZonedDateTime(2011, 12, 29, tz="Pacific/Apia"), + ), + # Another edge case + ( + ZonedDateTime(2010, 11, 7, 23, tz="America/St_Johns"), + ZonedDateTime( + 2010, 11, 7, tz="America/St_Johns", disambiguate="earlier" + ), + ), + # a day that starts twice + ( + ZonedDateTime( + 2016, + 2, + 20, + 23, + 45, + disambiguate="later", + tz="America/Sao_Paulo", + ), + ZonedDateTime( + 2016, 2, 20, tz="America/Sao_Paulo", disambiguate="raise" + ), + ), + ], +) +def test_start_of_day(d, expect): + assert d.start_of_day() == 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, @@ -580,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(): @@ -1927,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( @@ -1972,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): @@ -2007,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(