diff --git a/CHANGELOG.rst b/CHANGELOG.rst index bc9bf63..920a156 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,20 @@ 🚀 Changelog ============ +0.6.16 (2024-12-22) +------------------- + +- Fix but in ``ZonedDateTime`` ``repr()`` that would mangle some timezone names +- Make ``disambiguate`` argument optional, defaulting to ``"compatible"``. + + **Rationale**: This required parameter was a frequent source of + irritation for users. Although "explicit is better than implicit", + other modern libraries and standards also choose an (implicit) default. + For those that do want to enforce explicit handling of ambiguous times, + a special stubs file or other plugin may be introduced in the future. + +- Various small fixes to the docs + 0.6.15 (2024-12-11) ------------------- diff --git a/Cargo.lock b/Cargo.lock index 17df199..2d010cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 3 [[package]] name = "libc" -version = "0.2.168" +version = "0.2.169" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d" +checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" [[package]] name = "once_cell" diff --git a/README.md b/README.md index c1f2b62..6ca2c0b 100644 --- a/README.md +++ b/README.md @@ -8,21 +8,19 @@ [![](https://img.shields.io/github/actions/workflow/status/ariebovenberg/whenever/checks.yml?branch=main&style=flat-square)](https://github.com/ariebovenberg/whenever) [![](https://img.shields.io/readthedocs/whenever.svg?style=flat-square)](http://whenever.readthedocs.io/) -**Typed and DST-safe datetimes for Python, available in speedy Rust or pure Python.** +**Typed and DST-safe datetimes for Python, available in Rust or pure Python.** Do you cross your fingers every time you work with Python's datetime—hoping that you didn't mix naive and aware? or that you avoided its [other pitfalls](https://dev.arie.bovenberg.net/blog/python-datetime-pitfalls/)? -or that you properly accounted for Daylight Saving Time (DST)? There’s no way to be sure... ✨ Until now! ✨ -*Whenever* helps you write **correct** and **type checked** datetime code. -Mistakes become red squiggles in your IDE, instead of bugs in production. +*Whenever* helps you write **correct** and **type checked** datetime code, +using **well-established concepts** from modern libraries 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. -

@@ -35,9 +33,9 @@ If performance isn't your top priority, a **pure Python** version is available a RFC3339-parse, normalize, compare to now, shift, and change timezone (1M times)

-
+ [📖 Docs](https://whenever.readthedocs.io) | [🐍 PyPI](https://pypi.org/project/whenever/) | [🐙 GitHub](https://github.com/ariebovenberg/whenever) | @@ -48,7 +46,7 @@ If performance isn't your top priority, a **pure Python** version is available a
-> ⚠️ **Note**: Whenever is still on a pre-1.0 version. The API may change +> ⚠️ **Note**: A 1.0 release is coming soon. Until then, the API may change > as we gather feedback and improve the library. > Leave a ⭐️ on GitHub if you'd like to see how this project develops! @@ -112,7 +110,7 @@ as well as improved performance. However, it only fixes [*some* DST-related pitfalls](https://dev.arie.bovenberg.net/blog/python-datetime-pitfalls/#datetime-library-scorecard), and its performance has significantly [degraded over time](https://github.com/sdispater/pendulum/issues/818). Additionally, it's in maintenance limbo with only one release in the last four years, -and issues piling up unaddressed. +and many issues remaining unaddressed. ## Why use whenever? @@ -151,7 +149,7 @@ ZonedDateTime(2024-07-04 12:36:56+02:00[Europe/Paris]) >>> party_invite.add(hours=6) Traceback (most recent call last): ImplicitlyIgnoringDST: Adjusting a local datetime implicitly ignores DST [...] ->>> party_starts = party_invite.assume_tz("Europe/Amsterdam", disambiguate="earlier") +>>> party_starts = party_invite.assume_tz("Europe/Amsterdam") ZonedDateTime(2023-10-28 22:00:00+02:00[Europe/Amsterdam]) # DST-safe arithmetic @@ -216,9 +214,10 @@ For more details, see the licenses included in the distribution. ## Acknowledgements -This project is inspired by the following projects. Check them out! +This project is inspired by—and borrows 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/) 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/overview.rst b/docs/overview.rst index 7346e4d..6971ced 100644 --- a/docs/overview.rst +++ b/docs/overview.rst @@ -116,7 +116,7 @@ occuring twice or not at all. >>> livestream_starts.to_tz("America/New_York") ZonedDateTime(2022-10-24 13:00:00-04:00[America/New_York]) >>> # Local->Zoned may be ambiguous ->>> bus_departs.assume_tz("America/New_York", disambiguate="earlier") +>>> bus_departs.assume_tz("America/New_York") ZonedDateTime(2020-03-14 15:00:00-04:00[America/New_York]) .. seealso:: @@ -339,7 +339,7 @@ You can convert from local datetimes with the :meth:`~whenever.LocalDateTime.ass >>> n = LocalDateTime(2023, 12, 28, 11, 30) >>> n.assume_utc() Instant(2023-12-28 11:30:00Z) ->>> n.assume_tz("Europe/Amsterdam", disambiguate="compatible") +>>> n.assume_tz("Europe/Amsterdam") ZonedDateTime(2023-12-28 11:30:00+01:00[Europe/Amsterdam]) .. note:: @@ -388,9 +388,8 @@ Two common situations arise: The figure in the Python docs `here `_ also shows how this "extrapolation" makes sense graphically. -**Whenever** `refuses to guess `_ -and requires that you explicitly handle these situations -with the ``disambiguate=`` argument: +**Whenever** allows you to customize how to handle these situations +using the ``disambiguate`` argument: +------------------+-------------------------------------------------+ | ``disambiguate`` | Behavior in case of ambiguity | @@ -403,7 +402,7 @@ with the ``disambiguate=`` argument: | ``"later"`` | Choose the later of the two options | +------------------+-------------------------------------------------+ | ``"compatible"`` | Choose "earlier" for backward transitions and | -| | "later" for forward transitions. This matches | +| (default) | "later" for forward transitions. This matches | | | the behavior of other established libraries, | | | and the industry standard RFC 5545. | | | It corresponds to setting ``fold=0`` in the | @@ -418,24 +417,24 @@ with the ``disambiguate=`` argument: >>> ZonedDateTime(2023, 1, 1, tz=paris) ZonedDateTime(2023-01-01 00:00:00+01:00[Europe/Paris]) - >>> # Ambiguous: 1:30am occurs twice. Refuse to guess. - >>> ZonedDateTime(2023, 10, 29, 2, 30, tz=paris) + >>> # 1:30am occurs twice. Use 'raise' to reject ambiguous times. + >>> ZonedDateTime(2023, 10, 29, 2, 30, tz=paris, disambiguate="raise") Traceback (most recent call last): ... whenever.RepeatedTime: 2023-10-29 02:30:00 is repeated in timezone Europe/Paris - >>> # Repeated: explicitly choose the earlier option + >>> # Explicitly choose the earlier option >>> ZonedDateTime(2023, 10, 29, 2, 30, tz=paris, disambiguate="earlier") ZoneDateTime(2023-10-29 02:30:00+01:00[Europe/Paris]) - >>> # Skipped: 2:30am doesn't exist. - >>> ZonedDateTime(2023, 3, 26, 2, 30, tz=paris) + >>> # 2:30am doesn't exist on this date (clocks moved forward) + >>> ZonedDateTime(2023, 3, 26, 2, 30, tz=paris, disambiguate="raise") Traceback (most recent call last): ... whenever.SkippedTime: 2023-03-26 02:30:00 is skipped in timezone Europe/Paris - >>> # Non-existent: extrapolate to 3:30am - >>> ZonedDateTime(2023, 3, 26, 2, 30, tz=paris, disambiguate="later") + >>> # Default behavior is compatible with other libraries and standards + >>> ZonedDateTime(2023, 3, 26, 2, 30, tz=paris) ZonedDateTime(2023-03-26 03:30:00+02:00[Europe/Paris]) .. _arithmetic: @@ -464,8 +463,8 @@ TimeDelta(24:00:00) .. _add-subtract-time: -Addition and subtraction -~~~~~~~~~~~~~~~~~~~~~~~~ +Units of time +~~~~~~~~~~~~~ You can add or subtract various units of time from a datetime instance. @@ -475,7 +474,7 @@ ZonedDateTime(2023-12-28 17:00:00+01:00[Europe/Amsterdam]) The behavior arithmetic behavior is different for three categories of units: -1. Adding **years and months** may require truncation of the date. +1. Adding **years and months** may result in truncation of the date. For example, adding a month to August 31st results in September 31st, which isn't valid. In such cases, the date is truncated to the last day of the month. @@ -485,21 +484,25 @@ The behavior arithmetic behavior is different for three categories of units: >>> d.add(months=1) LocalDateTime(2023-09-30 12:00:00) - In case of dealing with :class:`~whenever.ZonedDateTime` or :class:`~whenever.SystemDateTime`, - there is a rare case where the resulting date might land the datetime in the middle of a DST transition. - For this reason, adding years or months to these types requires the ``disambiguate=`` argument: + .. note:: - .. code-block:: python + In case of dealing with :class:`~whenever.ZonedDateTime` or :class:`~whenever.SystemDateTime`, + there is a rare case where the resulting date might put the datetime in the middle of a DST transition. + For this reason, adding years or months to these types accepts the + ``disambiguate`` argument. By default, it tries to keep the same UTC offset, + and if that's not possible, it chooses the ``"compatible"`` option. - >>> d = ZonedDateTime(2023, 9, 29, 2, 15, tz="Europe/Amsterdam") - >>> d.add(months=1, disambiguate="raise") - Traceback (most recent call last): - ... - whenever.RepeatedTime: The resulting datetime is repeated in tz Europe/Amsterdam + .. code-block:: python + + >>> d = ZonedDateTime(2023, 9, 29, 2, 15, tz="Europe/Amsterdam") + >>> d.add(months=1, disambiguate="raise") + Traceback (most recent call last): + ... + whenever.RepeatedTime: 2023-10-29 02:15:00 is repeated in timezone 'Europe/Amsterdam' 2. Adding **days** only affects the calendar date. Adding a day to a datetime will not affect the local time of day. - This is usually same as adding 24 hours, **except** during DST transitions! + This is usually same as adding 24 hours, *except* during DST transitions! This behavior may seem strange at first, but it's the most intuitive when you consider that you'd expect postponing a meeting "to tomorrow" @@ -511,14 +514,16 @@ The behavior arithmetic behavior is different for three categories of units: >>> # on the eve of a DST transition >>> d = ZonedDateTime(2023, 3, 25, hour=12, tz="Europe/Amsterdam") - >>> d.add(days=1, disambiguate="raise") # a day later, still 12 o'clock + >>> d.add(days=1) # a day later, still 12 o'clock ZonedDateTime(2023-03-26 12:00:00+02:00[Europe/Amsterdam]) >>> d.add(hours=24) # 24 hours later (we skipped an hour overnight!) ZonedDateTime(2023-03-26 13:00:00+02:00[Europe/Amsterdam]) - As with months and years, adding days to a :class:`~whenever.ZonedDateTime` - or :class:`~whenever.SystemDateTime` requires the ``disambiguate=`` argument, - since the resulting date might land the datetime in a DST transition. + .. note:: + + As with months and years, adding days to a :class:`~whenever.ZonedDateTime` + or :class:`~whenever.SystemDateTime` accepts the ``disambiguate`` argument, + since the resulting date might put the datetime in a DST transition. 3. Adding **precise time units** (hours, minutes, seconds) never results in ambiguity. If an hour is skipped or repeated due to a DST transition, @@ -530,16 +535,6 @@ The behavior arithmetic behavior is different for three categories of units: >>> d.add(hours=24) # we skipped an hour overnight! ZonedDateTime(2023-03-26 13:00:00+02:00[Europe/Amsterdam]) - :class:`~whenever.LocalDateTime` also supports adding precise time units, - but requires the ``ignore_dst=True`` argument, to prevent - the common mistake of ignoring DST transitions by ignoring timezones. - - .. code-block:: python - - >>> d = LocalDateTime(2023, 3, 25, hour=12, tz="Europe/Amsterdam") - >>> d.add(hours=24, ignore_dst=True) # NOT recommended - ZonedDateTime(2023-03-26 13:00:00+02:00[Europe/Amsterdam]) - .. seealso:: Have a look at the documentation on :ref:`deltas ` for more details @@ -569,7 +564,8 @@ to the correct usage. >>> d = OffsetDateTime(2024, 3, 9, 13, offset=-7) >>> d.add(hours=24) Traceback (most recent call last): - ImplicitlyIgnoringDST: Adjusting a fixed offset datetime implicitly ignores DST [...] + ... + ImplicitlyIgnoringDST: Adjusting a fixed offset datetime implicitly ignores DST [...] >>> d.to_tz("America/Denver").add(hours=24) ZonedDateTime(2024-03-10 14:00:00-06:00[America/Denver]) >>> d.add(hours=24, ignore_dst=True) # NOT recommended @@ -584,16 +580,17 @@ to the correct usage. - :class:`~whenever.ZonedDateTime` and :class:`~whenever.SystemDateTime` account for DST and other timezone changes, thus adding precise time units is always correct. - Adding calendar units is also possible, but can result in ambiguity. - For example, if shifting the date puts it in the middle of a DST transition: + Adding calendar units is also possible, but may result in ambiguity in rare cases, + if the resulting datetime is in the middle of a DST transition: >>> d = ZonedDateTime(2024, 10, 3, 1, 15, tz="America/Denver") - >>> d.add(months=1) # 2024-11-03 01:15:00 would be ambiguous! + ZonedDateTime(2024-10-03 01:15:00-06:00[America/Denver]) + >>> d.add(months=1) + ZonedDateTime(2024-11-03 01:15:00-06:00[America/Denver]) + >>> d.add(months=1, disambiguate="raise") Traceback (most recent call last): ... - >>> d.add(months=1, disambiguate="later") - ZonedDateTime(2024-11-03 01:15:00-07:00[America/Denver]) - >>> d.add(hours=24) # no disambiguation necessary for precise units + whenever.RepeatedTime: 2024-11-03 01:15:00 is repeated in timezone 'America/Denver' - :class:`~whenever.LocalDateTime` doesn't have a timezone, so it can't account for DST or other clock changes. @@ -605,7 +602,9 @@ to the correct usage. >>> d.add(hours=2) # There could be a DST transition for all we know! Traceback (most recent call last): ... - >>> d.assume_tz("Europe/Amsterdam", disambiguate="earlier").add(hours=2) + whenever.ImplicitlyIgnoringDST: Adjusting a local datetime by time units + ignores DST and other timezone changes. [...] + >>> d.assume_tz("Europe/Amsterdam").add(hours=2) ZonedDateTime(2023-10-29 02:30:00+01:00[Europe/Amsterdam]) >>> d.add(hours=2, ignore_dst=True) # NOT recommended LocalDateTime(2024-10-03 03:30:00) @@ -623,7 +622,7 @@ Here is a summary of the arithmetic features for each type: +=======================+=========+=========+=========+==========+=========+ | Difference | ✅ | ✅ | ✅ | ✅ |⚠️ [3]_ | +-----------------------+---------+---------+---------+----------+---------+ -| add/subtract years, | ❌ |⚠️ [3]_ |🔶 [4]_ | 🔶 [4]_ | ✅ | +| add/subtract years, | ❌ |⚠️ [3]_ |✅ [4]_ | ✅ [4]_ | ✅ | | months, days | | | | | | +-----------------------+---------+---------+---------+----------+---------+ | add/subtract hours, | ✅ |⚠️ [3]_ | ✅ | ✅ |⚠️ [3]_ | @@ -631,7 +630,7 @@ Here is a summary of the arithmetic features for each type: +-----------------------+---------+---------+---------+----------+---------+ .. [3] Only possible by passing ``ignore_dst=True`` to the method. -.. [4] Only possible by passing ``disambiguate=...`` to the method. +.. [4] The result by be ambiguous in rare cases. Accepts the ``disambiguate`` argument. .. admonition:: Why even have ``ignore_dst``? Isn't it dangerous? @@ -798,8 +797,7 @@ methods to convert them. This makes it explicit what information is being assumed. >>> d = LocalDateTime.strptime("2023-10-29 02:30:00", "%Y-%m-%d %H:%M:%S") ->>> # handling ambiguity ->>> d.assume_tz("Europe/Amsterdam", disambiguate="earlier") +>>> d.assume_tz("Europe/Amsterdam") ZonedDateTime(2023-10-29 02:30:00+02:00[Europe/Amsterdam]) .. admonition:: Future plans @@ -994,7 +992,7 @@ On the other hand, if you'd like to preserve the local time on the clock and calculate the corresponding moment in time: >>> # take the wall clock time and assume the (new) system timezone (Amsterdam) ->>> d.local().assume_system_tz(disambiguate="earlier") +>>> d.local().assume_system_tz() SystemDateTime(2020-08-15 08:00:00+02:00) .. seealso:: diff --git a/pyproject.toml b/pyproject.toml index 1f086ac..b5cc756 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.15" +version = "0.6.16" description = "Modern datetime library for Python" requires-python = ">=3.9" classifiers = [ diff --git a/pysrc/whenever/__init__.pyi b/pysrc/whenever/__init__.pyi index aaa2fab..26cecdd 100644 --- a/pysrc/whenever/__init__.pyi +++ b/pysrc/whenever/__init__.pyi @@ -534,9 +534,7 @@ class ZonedDateTime(_KnowsInstantAndLocal): *, nanosecond: int = 0, tz: str, - disambiguate: Literal[ - "compatible", "raise", "earlier", "later" - ] = "raise", + disambiguate: Literal["compatible", "raise", "earlier", "later"] = ..., ) -> None: ... @property def tz(self) -> str: ... @@ -569,21 +567,21 @@ class ZonedDateTime(_KnowsInstantAndLocal): second: int = ..., nanosecond: int = ..., tz: str = ..., - disambiguate: Literal["compatible", "raise", "earlier", "later"], + disambiguate: Literal["compatible", "raise", "earlier", "later"] = ..., ) -> ZonedDateTime: ... def replace_date( self, d: Date, /, *, - disambiguate: Literal["compatible", "raise", "earlier", "later"], + disambiguate: Literal["compatible", "raise", "earlier", "later"] = ..., ) -> ZonedDateTime: ... def replace_time( self, t: Time, /, *, - disambiguate: Literal["compatible", "raise", "earlier", "later"], + disambiguate: Literal["compatible", "raise", "earlier", "later"] = ..., ) -> ZonedDateTime: ... @overload def add( @@ -599,19 +597,20 @@ class ZonedDateTime(_KnowsInstantAndLocal): milliseconds: float = 0, microseconds: float = 0, nanoseconds: int = 0, - disambiguate: Literal["compatible", "raise", "earlier", "later"], - ) -> ZonedDateTime: ... - @overload - def add( - self, - *, - hours: float = 0, - minutes: float = 0, - seconds: float = 0, - milliseconds: float = 0, - microseconds: float = 0, - nanoseconds: int = 0, + disambiguate: Literal["compatible", "raise", "earlier", "later"] = ..., ) -> ZonedDateTime: ... + # FUTURE: include this in strict stubs version + # @overload + # def add( + # self, + # *, + # hours: float = 0, + # minutes: float = 0, + # seconds: float = 0, + # milliseconds: float = 0, + # microseconds: float = 0, + # nanoseconds: int = 0, + # ) -> ZonedDateTime: ... @overload def add(self, d: TimeDelta, /) -> ZonedDateTime: ... @overload @@ -620,7 +619,7 @@ class ZonedDateTime(_KnowsInstantAndLocal): d: DateDelta | DateTimeDelta, /, *, - disambiguate: Literal["compatible", "raise", "earlier", "later"], + disambiguate: Literal["compatible", "raise", "earlier", "later"] = ..., ) -> ZonedDateTime: ... @overload def subtract( @@ -636,19 +635,20 @@ class ZonedDateTime(_KnowsInstantAndLocal): milliseconds: float = 0, microseconds: float = 0, nanoseconds: int = 0, - disambiguate: Literal["compatible", "raise", "earlier", "later"], - ) -> ZonedDateTime: ... - @overload - def subtract( - self, - *, - hours: float = 0, - minutes: float = 0, - seconds: float = 0, - milliseconds: float = 0, - microseconds: float = 0, - nanoseconds: int = 0, + disambiguate: Literal["compatible", "raise", "earlier", "later"] = ..., ) -> ZonedDateTime: ... + # FUTURE: include this in strict stubs version + # @overload + # def subtract( + # self, + # *, + # hours: float = 0, + # minutes: float = 0, + # seconds: float = 0, + # milliseconds: float = 0, + # microseconds: float = 0, + # nanoseconds: int = 0, + # ) -> ZonedDateTime: ... @overload def subtract(self, d: TimeDelta, /) -> ZonedDateTime: ... @overload @@ -657,13 +657,14 @@ class ZonedDateTime(_KnowsInstantAndLocal): d: DateDelta | DateTimeDelta, /, *, - disambiguate: Literal["compatible", "raise", "earlier", "later"], + disambiguate: Literal["compatible", "raise", "earlier", "later"] = ..., ) -> ZonedDateTime: ... - def __add__(self, delta: TimeDelta) -> ZonedDateTime: ... + # FUTURE: disable date components in strict stubs version + def __add__(self, delta: Delta) -> ZonedDateTime: ... @overload def __sub__(self, other: _KnowsInstant) -> TimeDelta: ... @overload - def __sub__(self, other: TimeDelta) -> ZonedDateTime: ... + def __sub__(self, other: Delta) -> ZonedDateTime: ... @final class SystemDateTime(_KnowsInstantAndLocal): @@ -677,9 +678,7 @@ class SystemDateTime(_KnowsInstantAndLocal): second: int = 0, *, nanosecond: int = 0, - disambiguate: Literal[ - "compatible", "raise", "earlier", "later" - ] = "raise", + disambiguate: Literal["compatible", "raise", "earlier", "later"] = ..., ) -> None: ... @classmethod def now(cls) -> SystemDateTime: ... @@ -706,21 +705,21 @@ class SystemDateTime(_KnowsInstantAndLocal): minute: int = ..., second: int = ..., nanosecond: int = ..., - disambiguate: Literal["compatible", "raise", "earlier", "later"], + disambiguate: Literal["compatible", "raise", "earlier", "later"] = ..., ) -> SystemDateTime: ... def replace_date( self, d: Date, /, *, - disambiguate: Literal["compatible", "raise", "earlier", "later"], + disambiguate: Literal["compatible", "raise", "earlier", "later"] = ..., ) -> SystemDateTime: ... def replace_time( self, t: Time, /, *, - disambiguate: Literal["compatible", "raise", "earlier", "later"], + disambiguate: Literal["compatible", "raise", "earlier", "later"] = ..., ) -> SystemDateTime: ... @overload def add( @@ -736,19 +735,20 @@ class SystemDateTime(_KnowsInstantAndLocal): milliseconds: float = 0, microseconds: float = 0, nanoseconds: int = 0, - disambiguate: Literal["compatible", "raise", "earlier", "later"], - ) -> SystemDateTime: ... - @overload - def add( - self, - *, - hours: float = 0, - minutes: float = 0, - seconds: float = 0, - milliseconds: float = 0, - microseconds: float = 0, - nanoseconds: int = 0, + disambiguate: Literal["compatible", "raise", "earlier", "later"] = ..., ) -> SystemDateTime: ... + # FUTURE: include this in strict stubs version + # @overload + # def add( + # self, + # *, + # hours: float = 0, + # minutes: float = 0, + # seconds: float = 0, + # milliseconds: float = 0, + # microseconds: float = 0, + # nanoseconds: int = 0, + # ) -> SystemDateTime: ... @overload def add(self, d: TimeDelta, /) -> SystemDateTime: ... @overload @@ -757,7 +757,7 @@ class SystemDateTime(_KnowsInstantAndLocal): d: DateDelta | DateTimeDelta, /, *, - disambiguate: Literal["compatible", "raise", "earlier", "later"], + disambiguate: Literal["compatible", "raise", "earlier", "later"] = ..., ) -> SystemDateTime: ... @overload def subtract( @@ -773,19 +773,20 @@ class SystemDateTime(_KnowsInstantAndLocal): milliseconds: float = 0, microseconds: float = 0, nanoseconds: int = 0, - disambiguate: Literal["compatible", "raise", "earlier", "later"], - ) -> SystemDateTime: ... - @overload - def subtract( - self, - *, - hours: float = 0, - minutes: float = 0, - seconds: float = 0, - milliseconds: float = 0, - microseconds: float = 0, - nanoseconds: int = 0, + disambiguate: Literal["compatible", "raise", "earlier", "later"] = ..., ) -> SystemDateTime: ... + # FUTURE: include this in strict stubs version + # @overload + # def subtract( + # self, + # *, + # hours: float = 0, + # minutes: float = 0, + # seconds: float = 0, + # milliseconds: float = 0, + # microseconds: float = 0, + # nanoseconds: int = 0, + # ) -> SystemDateTime: ... @overload def subtract(self, d: TimeDelta, /) -> SystemDateTime: ... @overload @@ -794,13 +795,14 @@ class SystemDateTime(_KnowsInstantAndLocal): d: DateDelta | DateTimeDelta, /, *, - disambiguate: Literal["compatible", "raise", "earlier", "later"], + disambiguate: Literal["compatible", "raise", "earlier", "later"] = ..., ) -> SystemDateTime: ... - def __add__(self, delta: TimeDelta) -> SystemDateTime: ... + # FUTURE: disable date components in strict stubs version + def __add__(self, delta: Delta) -> SystemDateTime: ... @overload def __sub__(self, other: _KnowsInstant) -> TimeDelta: ... @overload - def __sub__(self, other: TimeDelta) -> SystemDateTime: ... + def __sub__(self, other: Delta) -> SystemDateTime: ... @final class LocalDateTime(_KnowsLocal): diff --git a/pysrc/whenever/_pywhenever.py b/pysrc/whenever/_pywhenever.py index 121a502..58114c5 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.15" +__version__ = "0.6.16" import enum import re @@ -2424,7 +2424,7 @@ def date(self) -> Date: like :meth:`~LocalDateTime.assume_utc` or :meth:`~LocalDateTime.assume_tz`: - >>> date.at(time).assume_tz("Europe/London", disambiguate="compatible") + >>> date.at(time).assume_tz("Europe/London") """ return Date._from_py_unchecked(self._py_dt.date()) @@ -2442,7 +2442,7 @@ def time(self) -> Time: like :meth:`~LocalDateTime.assume_utc` or :meth:`~LocalDateTime.assume_tz`: - >>> time.on(date).assume_tz("Europe/Paris", disambiguate="compatible") + >>> time.on(date).assume_tz("Europe/Paris") """ return Time._from_py_unchecked(self._py_dt.time(), self._nanos) @@ -2467,8 +2467,8 @@ def replace(self: _T, /, **kwargs: Any) -> _T: ------- The same exceptions as the constructor may be raised. For system and zoned datetimes, - The ``disambiguate=`` keyword argument is **required** to - resolve ambiguities. For more information, see + The ``disambiguate`` keyword argument is recommended to + resolve ambiguities explicitly. For more information, see whenever.rtfd.io/en/latest/overview.html#ambiguity-in-timezones Example @@ -2478,7 +2478,7 @@ def replace(self: _T, /, **kwargs: Any) -> _T: LocalDateTime(2021-08-15 23:12:00) >>> >>> z = ZonedDateTime(2020, 8, 15, 23, 12, tz="Europe/London") - >>> z.replace(year=2021, disambiguate="raise") + >>> z.replace(year=2021) ZonedDateTime(2021-08-15T23:12:00+01:00) """ @@ -2491,16 +2491,10 @@ def replace_date(self: _T, date: Date, /, **kwargs) -> _T: >>> d.replace_date(Date(2021, 1, 1)) LocalDateTime(2021-01-01T04:00:00) >>> zdt = ZonedDateTime.now("Europe/London") - >>> zdt.replace_date(Date(2021, 1, 1), disambiguate="raise")) + >>> zdt.replace_date(Date(2021, 1, 1)) ZonedDateTime(2021-01-01T13:00:00.23439+00:00[Europe/London]) - Warning - ------- - The same exceptions as the constructor may be raised. - For system and zoned datetimes, - you will need to pass ``disambiguate=`` to resolve ambiguities. - For more information, see - whenever.rtfd.io/en/latest/overview.html#ambiguity-in-timezones + See :meth:`replace` for more information. """ def replace_time(self: _T, time: Time, /, **kwargs) -> _T: @@ -2512,14 +2506,10 @@ def replace_time(self: _T, time: Time, /, **kwargs) -> _T: >>> d.replace_time(Time(12, 30)) LocalDateTime(2020-08-15T12:30:00) >>> zdt = ZonedDateTime.now("Europe/London") - >>> zdt.replace_time(Time(12, 30), disambiguate="raise") + >>> zdt.replace_time(Time(12, 30)) ZonedDateTime(2024-06-15T12:30:00+01:00[Europe/London]) - Warning - ------- - The same exceptions as the constructor may be raised. - For system and zoned datetimes, - you will need to pass ``disambiguate=`` to resolve ambiguities. + See :meth:`replace` for more information. """ @abstractmethod @@ -2542,7 +2532,7 @@ def add( Arithmetic on datetimes is complicated. Additional keyword arguments ``ignore_dst`` and ``disambiguate`` - may be needed for certain types and situations. + may be relevant for certain types and situations. See :ref:`the docs on arithmetic ` for more information and the reasoning behind it. """ @@ -3585,14 +3575,7 @@ def replace_date( ) -> OffsetDateTime: """Construct a new instance with the date replaced. - Important - --------- - Replacing the date of an offset datetime implicitly ignores DST - and other timezone changes. This because it isn't guaranteed that - the same offset will be valid at the new date. - If you want to account for DST, convert to a ``ZonedDateTime`` first. - Or, if you want to ignore DST and accept potentially incorrect offsets, - pass ``ignore_dst=True`` to this method. + See the ``replace()`` method for more information. """ if ignore_dst is not True: raise ImplicitlyIgnoringDST(ADJUST_OFFSET_DATETIME_MSG) @@ -3608,14 +3591,7 @@ def replace_time( ) -> OffsetDateTime: """Construct a new instance with the time replaced. - Important - --------- - Replacing the time of an offset datetime implicitly ignores DST - and other timezone changes. This because it isn't guaranteed that - the same offset will be valid at the new time. - If you want to account for DST, convert to a ``ZonedDateTime`` first. - Or, if you want to ignore DST and accept potentially incorrect offsets, - pass ``ignore_dst=True`` to this method. + See the ``replace()`` method for more information. """ if ignore_dst is not True: raise ImplicitlyIgnoringDST(ADJUST_OFFSET_DATETIME_MSG) @@ -3939,7 +3915,7 @@ def __init__( *, nanosecond: int = 0, tz: str, - disambiguate: Disambiguate = "raise", + disambiguate: Disambiguate = "compatible", ) -> None: self._py_dt = _resolve_ambiguity( _datetime( @@ -3951,7 +3927,6 @@ def __init__( second, 0, zone := ZoneInfo(tz), - fold=_as_fold(disambiguate), ), zone, disambiguate, @@ -4099,84 +4074,78 @@ def from_py_datetime(cls, d: _datetime, /) -> ZonedDateTime: ) def replace_date( - self, date: Date, /, disambiguate: Disambiguate + self, date: Date, /, disambiguate: Disambiguate | None = None ) -> ZonedDateTime: """Construct a new instance with the date replaced. - Important - --------- - Replacing the date of a ZonedDateTime may result in an ambiguous time - (e.g. during a DST transition). Therefore, you must explicitly - specify how to handle such a situation using the ``disambiguate`` argument. - - See `the documentation `_ - for more information. + See the ``replace()`` method for more information. """ return self._from_py_unchecked( _resolve_ambiguity( - _datetime.combine(date._py_date, self._py_dt.timetz()).replace( - fold=_as_fold(disambiguate) - ), + _datetime.combine(date._py_date, self._py_dt.timetz()), # mypy doesn't know that tzinfo is always a ZoneInfo here self._py_dt.tzinfo, # type: ignore[arg-type] - disambiguate, + # mypy doesn't know that offset is never None here + disambiguate or self._py_dt.utcoffset(), # type: ignore[arg-type] ), self._nanos, ) def replace_time( - self, time: Time, /, disambiguate: Disambiguate + self, time: Time, /, disambiguate: Disambiguate | None = None ) -> ZonedDateTime: """Construct a new instance with the time replaced. - Important - --------- - Replacing the time of a ZonedDateTime may result in an ambiguous time - (e.g. during a DST transition). Therefore, you must explicitly - specify how to handle such a situation using the ``disambiguate`` argument. - - See `the documentation `_ - for more information. + See the ``replace()`` method for more information. """ return self._from_py_unchecked( _resolve_ambiguity( _datetime.combine( self._py_dt, time._py_time, self._py_dt.tzinfo - ).replace(fold=_as_fold(disambiguate)), + ), # mypy doesn't know that tzinfo is always a ZoneInfo here self._py_dt.tzinfo, # type: ignore[arg-type] - disambiguate, + # mypy doesn't know that offset is never None here + disambiguate or self._py_dt.utcoffset(), # type: ignore[arg-type] ), time._nanos, ) def replace( - self, /, disambiguate: Disambiguate, **kwargs: Any + self, /, disambiguate: Disambiguate | None = None, **kwargs: Any ) -> ZonedDateTime: """Construct a new instance with the given fields replaced. Important --------- Replacing fields of a ZonedDateTime may result in an ambiguous time - (e.g. during a DST transition). Therefore, you must explicitly + (e.g. during a DST transition). Therefore, it's recommended to specify how to handle such a situation using the ``disambiguate`` argument. + By default, if the tz remains the same, the offset is used to disambiguate + if possible, falling back to the "compatible" strategy if needed. + See `the documentation `_ for more information. """ + _check_invalid_replace_kwargs(kwargs) try: tz = kwargs.pop("tz") except KeyError: pass else: - kwargs["tzinfo"] = ZoneInfo(tz) + kwargs["tzinfo"] = zoneinfo_new = ZoneInfo(tz) + if zoneinfo_new is not self._py_dt.tzinfo: + disambiguate = disambiguate or "compatible" nanos = _pop_nanos_kwarg(kwargs, self._nanos) + return self._from_py_unchecked( _resolve_ambiguity( - self._py_dt.replace(fold=_as_fold(disambiguate), **kwargs), + self._py_dt.replace(**kwargs), kwargs.get("tzinfo", self._py_dt.tzinfo), - disambiguate, + # mypy doesn't know that offset is never None here + disambiguate or self._py_dt.utcoffset(), # type: ignore[arg-type] ), nanos, ) @@ -4189,7 +4158,7 @@ def tz(self) -> str: def __hash__(self) -> int: return hash((self._py_dt.astimezone(_UTC), self._nanos)) - def __add__(self, delta: TimeDelta) -> ZonedDateTime: + def __add__(self, delta: Delta) -> ZonedDateTime: """Add an amount of time, accounting for timezone changes (e.g. DST). See `the docs `_ @@ -4206,8 +4175,13 @@ def __add__(self, delta: TimeDelta) -> ZonedDateTime: ).astimezone(self._py_dt.tzinfo), nanos, ) - elif isinstance(delta, (DateDelta, DateTimeDelta)): - raise TypeError(SHIFT_OPERATOR_CALENDAR_ZONED_MSG) + elif isinstance(delta, DateDelta): + return self.replace_date(self.date() + delta) + elif isinstance(delta, DateTimeDelta): + return ( + self.replace_date(self.date() + delta._date_part) + + delta._time_part + ) return NotImplemented @overload @@ -4238,7 +4212,7 @@ def add(self, *args, **kwargs) -> ZonedDateTime: --------- Shifting a ``ZonedDateTime`` with **calendar units** (e.g. months, weeks) may result in an ambiguous time (e.g. during a DST transition). - Therefore, when adding calendar units, you must explicitly + Therefore, when adding calendar units, it's recommended to specify how to handle such a situation using the ``disambiguate`` argument. See `the documentation `_ @@ -4254,7 +4228,7 @@ def subtract(self, *args, **kwargs) -> ZonedDateTime: --------- Shifting a ``ZonedDateTime`` with **calendar units** (e.g. months, weeks) may result in an ambiguous time (e.g. during a DST transition). - Therefore, when adding calendar units, you must explicitly + Therefore, when adding calendar units, it's recommended to specify how to handle such a situation using the ``disambiguate`` argument. See `the documentation `_ @@ -4269,7 +4243,7 @@ def _shift( delta: Delta | _UNSET = _UNSET, /, *, - disambiguate: Disambiguate | _UNSET = _UNSET, + disambiguate: Disambiguate | None = None, **kwargs, ) -> ZonedDateTime: if kwargs: @@ -4304,16 +4278,11 @@ def _shift_kwargs( milliseconds: float = 0, microseconds: float = 0, nanoseconds: int = 0, - disambiguate: Disambiguate, # may be _UNSET sentinel + disambiguate: Disambiguate | None, ) -> ZonedDateTime: months_total = sign * (years * 12 + months) days_total = sign * (weeks * 7 + days) if months_total or days_total: - if disambiguate is _UNSET: - raise TypeError( - "'disambiguate' keyword argument must be provided when " - "adding/subtracting calendar units" - ) self = self.replace_date( self.date()._add_months(months_total)._add_days(days_total), disambiguate=disambiguate, @@ -4332,9 +4301,9 @@ def is_ambiguous(self) -> bool: Example ------- - >>> ZonedDateTime(2020, 8, 15, 23, tz="Europe/London", disambiguate="later").ambiguous() + >>> ZonedDateTime(2020, 8, 15, 23, tz="Europe/London").is_ambiguous() False - >>> ZonedDateTime(2023, 10, 29, 2, 15, tz="Europe/Amsterdam", disambiguate="later").ambiguous() + >>> ZonedDateTime(2023, 10, 29, 2, 15, tz="Europe/Amsterdam").is_ambiguous() True """ # We make use of a quirk of the standard library here: @@ -4342,7 +4311,7 @@ def is_ambiguous(self) -> bool: return self._py_dt.astimezone(_UTC) != self._py_dt def __repr__(self) -> str: - return f"ZonedDateTime({str(self).replace('T', ' ')})" + return f"ZonedDateTime({str(self).replace('T', ' ', 1)})" # a custom pickle implementation with a smaller payload def __reduce__(self) -> tuple[object, ...]: @@ -4414,7 +4383,7 @@ def __init__( second: int = 0, *, nanosecond: int = 0, - disambiguate: Disambiguate = "raise", + disambiguate: Disambiguate = "compatible", ) -> None: self._py_dt = _resolve_system_ambiguity( _datetime( @@ -4425,7 +4394,6 @@ def __init__( minute, second, 0, - fold=_as_fold(disambiguate), ), disambiguate, ) @@ -4517,62 +4485,46 @@ def __repr__(self) -> str: # FUTURE: expose the tzname? def replace_date( - self, date: Date, /, disambiguate: Disambiguate + self, date: Date, /, disambiguate: Disambiguate | None = None ) -> SystemDateTime: """Construct a new instance with the date replaced. - Important - --------- - Replacing the date of a SystemDateTime may result in an ambiguous time - (e.g. during a DST transition). Therefore, you must explicitly - specify how to handle such a situation using the ``disambiguate`` argument. - - See `the documentation `_ - for more information. + See the ``replace()`` method for more information. """ return self._from_py_unchecked( _resolve_system_ambiguity( - _datetime.combine(date._py_date, self._py_dt.time()).replace( - fold=_as_fold(disambiguate) - ), - disambiguate, + _datetime.combine(date._py_date, self._py_dt.time()), + # mypy doesn't know that offset is never None here + disambiguate or self._py_dt.utcoffset(), # type: ignore[arg-type] ), self._nanos, ) def replace_time( - self, time: Time, /, disambiguate: Disambiguate + self, time: Time, /, disambiguate: Disambiguate | None = None ) -> SystemDateTime: """Construct a new instance with the time replaced. - Important - --------- - Replacing the time of a SystemDateTime may result in an ambiguous time - (e.g. during a DST transition). Therefore, you must explicitly - specify how to handle such a situation using the ``disambiguate`` argument. - - See `the documentation `_ - for more information. + See the ``replace()`` method for more information. """ return self._from_py_unchecked( _resolve_system_ambiguity( - _datetime.combine(self._py_dt, time._py_time).replace( - fold=_as_fold(disambiguate) - ), - disambiguate, + _datetime.combine(self._py_dt, time._py_time), + # mypy doesn't know that offset is never None here + disambiguate or self._py_dt.utcoffset(), # type: ignore[arg-type] ), time._nanos, ) def replace( - self, /, disambiguate: Disambiguate, **kwargs: Any + self, /, disambiguate: Disambiguate | None = None, **kwargs: Any ) -> SystemDateTime: """Construct a new instance with the given fields replaced. Important --------- Replacing fields of a SystemDateTime may result in an ambiguous time - (e.g. during a DST transition). Therefore, you must explicitly + (e.g. during a DST transition). Therefore, it's recommended to specify how to handle such a situation using the ``disambiguate`` argument. See `the documentation `_ @@ -4582,10 +4534,9 @@ def replace( nanos = _pop_nanos_kwarg(kwargs, self._nanos) return self._from_py_unchecked( _resolve_system_ambiguity( - self._py_dt.replace( - tzinfo=None, fold=_as_fold(disambiguate), **kwargs - ), - disambiguate, + self._py_dt.replace(tzinfo=None, **kwargs), + # mypy doesn't know that offset is never None here + disambiguate or self._py_dt.utcoffset(), # type: ignore[arg-type] ), nanos, ) @@ -4607,8 +4558,13 @@ def __add__(self, delta: TimeDelta) -> SystemDateTime: return self._from_py_unchecked( (py_dt + _timedelta(seconds=delta_secs)).astimezone(), nanos ) - elif isinstance(delta, (DateDelta, DateTimeDelta)): - raise TypeError(SHIFT_OPERATOR_CALENDAR_ZONED_MSG) + elif isinstance(delta, DateDelta): + return self.replace_date(self.date() + delta) + elif isinstance(delta, DateTimeDelta): + return ( + self.replace_date(self.date() + delta._date_part) + + delta._time_part + ) return NotImplemented @overload @@ -4639,7 +4595,7 @@ def add(self, *args, **kwargs) -> SystemDateTime: --------- Shifting a ``SystemDateTime`` with **calendar units** (e.g. months, weeks) may result in an ambiguous time (e.g. during a DST transition). - Therefore, when adding calendar units, you must explicitly + Therefore, when adding calendar units, it's recommended to specify how to handle such a situation using the ``disambiguate`` argument. See `the documentation `_ @@ -4655,7 +4611,7 @@ def subtract(self, *args, **kwargs) -> SystemDateTime: --------- Shifting a ``SystemDateTime`` with **calendar units** (e.g. months, weeks) may result in an ambiguous time (e.g. during a DST transition). - Therefore, when adding calendar units, you must explicitly + Therefore, when adding calendar units, it's recommended to specify how to handle such a situation using the ``disambiguate`` argument. See `the documentation `_ @@ -4670,7 +4626,7 @@ def _shift( delta: Delta | _UNSET = _UNSET, /, *, - disambiguate: Disambiguate | _UNSET = _UNSET, + disambiguate: Disambiguate | None = None, **kwargs, ) -> SystemDateTime: if kwargs: @@ -4705,16 +4661,11 @@ def _shift_kwargs( milliseconds: float = 0, microseconds: float = 0, nanoseconds: int = 0, - disambiguate: Disambiguate, # may be _UNSET sentinel + disambiguate: Disambiguate | None, ) -> SystemDateTime: months_total = sign * (years * 12 + months) days_total = sign * (weeks * 7 + days) if months_total or days_total: - if disambiguate is _UNSET: - raise TypeError( - "'disambiguate' keyword argument must be provided when " - "adding/subtracting calendar units" - ) self = self.replace_date( self.date()._add_months(months_total)._add_days(days_total), disambiguate=disambiguate, @@ -5112,7 +5063,7 @@ def assume_fixed_offset( ) def assume_tz( - self, tz: str, /, disambiguate: Disambiguate + self, tz: str, /, disambiguate: Disambiguate = "compatible" ) -> ZonedDateTime: """Assume the datetime is in the given timezone, creating a ``ZonedDateTime``. @@ -5133,16 +5084,16 @@ def assume_tz( """ return ZonedDateTime._from_py_unchecked( _resolve_ambiguity( - self._py_dt.replace( - tzinfo=(zone := ZoneInfo(tz)), fold=_as_fold(disambiguate) - ), + self._py_dt.replace(tzinfo=(zone := ZoneInfo(tz))), zone, disambiguate, ), self._nanos, ) - def assume_system_tz(self, disambiguate: Disambiguate) -> SystemDateTime: + def assume_system_tz( + self, disambiguate: Disambiguate = "compatible" + ) -> SystemDateTime: """Assume the datetime is in the system timezone, creating a ``SystemDateTime``. @@ -5162,10 +5113,7 @@ def assume_system_tz(self, disambiguate: Disambiguate) -> SystemDateTime: SystemDateTime(2020-08-15 23:12:00-04:00) """ return SystemDateTime._from_py_unchecked( - _resolve_system_ambiguity( - self._py_dt.replace(fold=_as_fold(disambiguate)), - disambiguate, - ), + _resolve_system_ambiguity(self._py_dt, disambiguate), self._nanos, ) @@ -5239,7 +5187,7 @@ class ImplicitlyIgnoringDST(TypeError): SHIFT_LOCAL_MSG = ( "Adding or subtracting a (date)time delta to a local datetime " "implicitly ignores DST transitions and other timezone " - "changes. Instead, use the `add` or `subtract` method." + "changes. Use the `add` or `subtract` method instead." ) DIFF_OPERATOR_LOCAL_MSG = ( @@ -5254,13 +5202,6 @@ class ImplicitlyIgnoringDST(TypeError): ) -SHIFT_OPERATOR_CALENDAR_ZONED_MSG = ( - "Addition/subtraction of calendar units on a Zoned/System-DateTime requires " - "explicit disambiguation. Use the `add`/`subtract` methods instead. " - "For example, instead of `dt + delta` use `dt.add(delta, disambiguate=...)`." -) - - TIMESTAMP_DST_MSG = ( "Converting from a timestamp with a fixed offset implicitly ignores DST " "and other timezone changes. To perform a DST-safe conversion, use " @@ -5292,8 +5233,11 @@ class ImplicitlyIgnoringDST(TypeError): def _resolve_ambiguity( - dt: _datetime, zone: ZoneInfo, disambiguate: Disambiguate + dt: _datetime, zone: ZoneInfo, disambiguate: Disambiguate | _timedelta ) -> _datetime: + if isinstance(disambiguate, _timedelta): + return _resolve_ambiguity_using_prev_offset(dt, disambiguate) + dt = dt.replace(fold=_as_fold(disambiguate)) dt_utc = dt.astimezone(_UTC) # Non-existent times: they don't survive a UTC roundtrip if dt_utc.astimezone(zone) != dt: @@ -5303,7 +5247,7 @@ def _resolve_ambiguity( # In gaps, the relationship between # fold and earlier/later is reversed dt = dt.replace(fold=not dt.fold) - # perform the normalisation, shifting away from non-existent times + # Perform the normalisation, shifting away from non-existent times dt = dt.astimezone(_UTC).astimezone(zone) # Ambiguous times: they're never equal to other timezones elif disambiguate == "raise" and dt_utc != dt: @@ -5311,6 +5255,22 @@ def _resolve_ambiguity( return dt +def _resolve_ambiguity_using_prev_offset( + dt: _datetime, + prev_offset: _timedelta, +) -> _datetime: + if prev_offset == dt.utcoffset(): + pass + elif prev_offset == dt.replace(fold=not dt.fold).utcoffset(): + dt = dt.replace(fold=not dt.fold) + else: + # No offset match. Setting fold=0 adopts the 'compatible' strategy + dt = dt.replace(fold=0) + + # This roundtrip ensures skipped times are shifted + return dt.astimezone(_UTC).astimezone(dt.tzinfo) + + # Whether the fold of a system time needs to be flipped in a gap # was changed (fixed) in Python 3.12. See cpython/issues/83861 _requires_flip: Callable[[Disambiguate], bool] = ( @@ -5318,11 +5278,15 @@ def _resolve_ambiguity( ) +# FUTURE: document that this isn't threadsafe (system tz may change) def _resolve_system_ambiguity( - dt: _datetime, disambiguate: Disambiguate + dt: _datetime, disambiguate: Disambiguate | _timedelta ) -> _datetime: assert dt.tzinfo is None - norm = dt.astimezone(_UTC).astimezone() + if isinstance(disambiguate, _timedelta): + return _resolve_system_ambiguity_using_prev_offset(dt, disambiguate) + dt = dt.replace(fold=_as_fold(disambiguate)) + norm = dt.astimezone(_UTC).astimezone() # going through UTC resolves gaps # Non-existent times: they don't survive a UTC roundtrip if norm.replace(tzinfo=None) != dt: if disambiguate == "raise": @@ -5339,6 +5303,29 @@ def _resolve_system_ambiguity( return norm +def _resolve_system_ambiguity_using_prev_offset( + dt: _datetime, prev_offset: _timedelta +) -> _datetime: + if dt.astimezone(_UTC).astimezone().utcoffset() == prev_offset: + pass + elif ( + dt.replace(fold=not dt.fold).astimezone(_UTC).astimezone().utcoffset() + == prev_offset + ): + dt = dt.replace(fold=not dt.fold) + else: # rare: no offset match. + # We account for this CPython bug: cpython/issues/83861 + if ( + sys.version_info < (3, 12) + # i.e. it's in a gap + and dt.astimezone(_UTC).astimezone().replace(tzinfo=None) != dt + ): # pragma: no cover + dt = dt.replace(fold=not dt.fold) + else: + dt = dt.replace(fold=0) + return dt.astimezone(_UTC).astimezone() + + def _load_offset(offset: int | TimeDelta, /) -> _timezone: if isinstance(offset, int): return _timezone(_timedelta(hours=offset)) diff --git a/src/common.rs b/src/common.rs index b7d30fe..53228af 100644 --- a/src/common.rs +++ b/src/common.rs @@ -880,11 +880,11 @@ impl Disambiguate { kwargs: &mut KwargIter, str_disambiguate: *mut PyObject, fname: &str, - ) -> PyResult { + ) -> PyResult> { match kwargs.next() { Some((name, value)) if kwargs.len() == 1 => { if name.kwarg_eq(str_disambiguate) { - Self::from_py(value) + Self::from_py(value).map(Some) } else { Err(type_err!( "{}() got an unexpected keyword argument {}", @@ -898,20 +898,11 @@ impl Disambiguate { fname, kwargs.len() )), - None => Err(type_err!( - "{}() missing 1 required keyword-only argument: 'disambiguate'", - fname - )), + None => Ok(None), } } } -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub(crate) enum Ambiguity { - Fold, - Gap, -} - pub(crate) unsafe extern "C" fn generic_dealloc(slf: *mut PyObject) { let cls = Py_TYPE(slf); let tp_free = PyType_GetSlot(cls, Py_tp_free); diff --git a/src/docstrings.rs b/src/docstrings.rs index 1556fb2..a33e954 100644 --- a/src/docstrings.rs +++ b/src/docstrings.rs @@ -740,7 +740,7 @@ Example OffsetDateTime(2020-08-15 23:12:00+02:00) "; pub(crate) const LOCALDATETIME_ASSUME_SYSTEM_TZ: &CStr = c"\ -assume_system_tz($self, disambiguate) +assume_system_tz($self, disambiguate='compatible') -- Assume the datetime is in the system timezone, @@ -762,7 +762,7 @@ Example SystemDateTime(2020-08-15 23:12:00-04:00) "; pub(crate) const LOCALDATETIME_ASSUME_TZ: &CStr = c"\ -assume_tz($self, tz, /, disambiguate) +assume_tz($self, tz, /, disambiguate='compatible') -- Assume the datetime is in the given timezone, @@ -1179,14 +1179,7 @@ replace_date($self, date, /, *, ignore_dst=False) Construct a new instance with the date replaced. -Important ---------- -Replacing the date of an offset datetime implicitly ignores DST -and other timezone changes. This because it isn't guaranteed that -the same offset will be valid at the new date. -If you want to account for DST, convert to a ``ZonedDateTime`` first. -Or, if you want to ignore DST and accept potentially incorrect offsets, -pass ``ignore_dst=True`` to this method. +See the ``replace()`` method for more information. "; pub(crate) const OFFSETDATETIME_REPLACE_TIME: &CStr = c"\ replace_time($self, time, /, *, ignore_dst=False) @@ -1194,14 +1187,7 @@ replace_time($self, time, /, *, ignore_dst=False) Construct a new instance with the time replaced. -Important ---------- -Replacing the time of an offset datetime implicitly ignores DST -and other timezone changes. This because it isn't guaranteed that -the same offset will be valid at the new time. -If you want to account for DST, convert to a ``ZonedDateTime`` first. -Or, if you want to ignore DST and accept potentially incorrect offsets, -pass ``ignore_dst=True`` to this method. +See the ``replace()`` method for more information. "; pub(crate) const OFFSETDATETIME_STRPTIME: &CStr = c"\ strptime(s, /, fmt) @@ -1250,7 +1236,7 @@ Important --------- Shifting a ``SystemDateTime`` with **calendar units** (e.g. months, weeks) may result in an ambiguous time (e.g. during a DST transition). -Therefore, when adding calendar units, you must explicitly +Therefore, when adding calendar units, it's recommended to specify how to handle such a situation using the ``disambiguate`` argument. See `the documentation `_ @@ -1315,41 +1301,27 @@ Construct a new instance with the given fields replaced. Important --------- Replacing fields of a SystemDateTime may result in an ambiguous time -(e.g. during a DST transition). Therefore, you must explicitly +(e.g. during a DST transition). Therefore, it's recommended to specify how to handle such a situation using the ``disambiguate`` argument. See `the documentation `_ for more information. "; pub(crate) const SYSTEMDATETIME_REPLACE_DATE: &CStr = c"\ -replace_date($self, date, /, disambiguate) +replace_date($self, date, /, disambiguate=None) -- Construct a new instance with the date replaced. -Important ---------- -Replacing the date of a SystemDateTime may result in an ambiguous time -(e.g. during a DST transition). Therefore, you must explicitly -specify how to handle such a situation using the ``disambiguate`` argument. - -See `the documentation `_ -for more information. +See the ``replace()`` method for more information. "; pub(crate) const SYSTEMDATETIME_REPLACE_TIME: &CStr = c"\ -replace_time($self, time, /, disambiguate) +replace_time($self, time, /, disambiguate=None) -- Construct a new instance with the time replaced. -Important ---------- -Replacing the time of a SystemDateTime may result in an ambiguous time -(e.g. during a DST transition). Therefore, you must explicitly -specify how to handle such a situation using the ``disambiguate`` argument. - -See `the documentation `_ -for more information. +See the ``replace()`` method for more information. "; pub(crate) const SYSTEMDATETIME_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) @@ -1361,7 +1333,7 @@ Important --------- Shifting a ``SystemDateTime`` with **calendar units** (e.g. months, weeks) may result in an ambiguous time (e.g. during a DST transition). -Therefore, when adding calendar units, you must explicitly +Therefore, when adding calendar units, it's recommended to specify how to handle such a situation using the ``disambiguate`` argument. See `the documentation `_ @@ -1656,7 +1628,7 @@ Important --------- Shifting a ``ZonedDateTime`` with **calendar units** (e.g. months, weeks) may result in an ambiguous time (e.g. during a DST transition). -Therefore, when adding calendar units, you must explicitly +Therefore, when adding calendar units, it's recommended to specify how to handle such a situation using the ``disambiguate`` argument. See `the documentation `_ @@ -1727,9 +1699,9 @@ Whether the local time is ambiguous, e.g. due to a DST transition. Example ------- ->>> ZonedDateTime(2020, 8, 15, 23, tz=\"Europe/London\", disambiguate=\"later\").ambiguous() +>>> ZonedDateTime(2020, 8, 15, 23, tz=\"Europe/London\").is_ambiguous() False ->>> ZonedDateTime(2023, 10, 29, 2, 15, tz=\"Europe/Amsterdam\", disambiguate=\"later\").ambiguous() +>>> ZonedDateTime(2023, 10, 29, 2, 15, tz=\"Europe/Amsterdam\").is_ambiguous() True "; pub(crate) const ZONEDDATETIME_NOW: &CStr = c"\ @@ -1764,41 +1736,30 @@ Construct a new instance with the given fields replaced. Important --------- Replacing fields of a ZonedDateTime may result in an ambiguous time -(e.g. during a DST transition). Therefore, you must explicitly +(e.g. during a DST transition). Therefore, it's recommended to specify how to handle such a situation using the ``disambiguate`` argument. +By default, if the tz remains the same, the offset is used to disambiguate +if possible, falling back to the \"compatible\" strategy if needed. + See `the documentation `_ for more information. "; pub(crate) const ZONEDDATETIME_REPLACE_DATE: &CStr = c"\ -replace_date($self, date, /, disambiguate) +replace_date($self, date, /, disambiguate=None) -- Construct a new instance with the date replaced. -Important ---------- -Replacing the date of a ZonedDateTime may result in an ambiguous time -(e.g. during a DST transition). Therefore, you must explicitly -specify how to handle such a situation using the ``disambiguate`` argument. - -See `the documentation `_ -for more information. +See the ``replace()`` method for more information. "; pub(crate) const ZONEDDATETIME_REPLACE_TIME: &CStr = c"\ -replace_time($self, time, /, disambiguate) +replace_time($self, time, /, disambiguate=None) -- Construct a new instance with the time replaced. -Important ---------- -Replacing the time of a ZonedDateTime may result in an ambiguous time -(e.g. during a DST transition). Therefore, you must explicitly -specify how to handle such a situation using the ``disambiguate`` argument. - -See `the documentation `_ -for more information. +See the ``replace()`` method for more information. "; 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) @@ -1810,7 +1771,7 @@ Important --------- Shifting a ``ZonedDateTime`` with **calendar units** (e.g. months, weeks) may result in an ambiguous time (e.g. during a DST transition). -Therefore, when adding calendar units, you must explicitly +Therefore, when adding calendar units, it's recommended to specify how to handle such a situation using the ``disambiguate`` argument. See `the documentation `_ @@ -1956,7 +1917,7 @@ To perform the inverse, use :meth:`Date.at` and a method like :meth:`~LocalDateTime.assume_utc` or :meth:`~LocalDateTime.assume_tz`: ->>> date.at(time).assume_tz(\"Europe/London\", disambiguate=\"compatible\") +>>> date.at(time).assume_tz(\"Europe/London\") "; pub(crate) const KNOWSLOCAL_TIME: &CStr = c"\ time($self) @@ -1975,13 +1936,12 @@ To perform the inverse, use :meth:`Time.on` and a method like :meth:`~LocalDateTime.assume_utc` or :meth:`~LocalDateTime.assume_tz`: ->>> time.on(date).assume_tz(\"Europe/Paris\", disambiguate=\"compatible\") +>>> time.on(date).assume_tz(\"Europe/Paris\") "; pub(crate) const ADJUST_LOCAL_DATETIME_MSG: &str = "Adjusting a local datetime by time units (e.g. hours and minutess) ignores DST and other timezone changes. To perform DST-safe operations, convert to a ZonedDateTime first. Or, if you don't know the timezone and accept potentially incorrect results during DST transitions, pass `ignore_dst=True`. For more information, see whenever.rtfd.io/en/latest/overview.html#dst-safe-arithmetic"; pub(crate) const ADJUST_OFFSET_DATETIME_MSG: &str = "Adjusting a fixed offset datetime implicitly ignores DST and other timezone changes. To perform DST-safe operations, convert to a ZonedDateTime first. Or, if you don't know the timezone and accept potentially incorrect results during DST transitions, pass `ignore_dst=True`. For more information, see whenever.rtfd.io/en/latest/overview.html#dst-safe-arithmetic"; pub(crate) const DIFF_LOCAL_MSG: &str = "The difference between two local datetimes implicitly ignores DST transitions and other timezone changes. To perform DST-safe operations, convert to a ZonedDateTime first. Or, if you don't know the timezone and accept potentially incorrect results during DST transitions, pass `ignore_dst=True`. For more information, see whenever.rtfd.io/en/latest/overview.html#dst-safe-arithmetic"; pub(crate) const DIFF_OPERATOR_LOCAL_MSG: &str = "The difference between two local datetimes implicitly ignores DST transitions and other timezone changes. Use the `difference` method instead."; pub(crate) const OFFSET_NOW_DST_MSG: &str = "Getting the current time with a fixed offset implicitly ignores DST and other timezone changes. Instead, use `Instant.now()` or `ZonedDateTime.now()` if you know the timezone. Or, if you want to ignore DST and accept potentially incorrect offsets, pass `ignore_dst=True` to this method. For more information, see whenever.rtfd.io/en/latest/overview.html#dst-safe-arithmetic"; -pub(crate) const SHIFT_LOCAL_MSG: &str = "Adding or subtracting a (date)time delta to a local datetime implicitly ignores DST transitions and other timezone changes. Instead, use the `add` or `subtract` method."; -pub(crate) const SHIFT_OPERATOR_CALENDAR_ZONED_MSG: &str = "Addition/subtraction of calendar units on a Zoned/System-DateTime requires explicit disambiguation. Use the `add`/`subtract` methods instead. For example, instead of `dt + delta` use `dt.add(delta, disambiguate=...)`."; +pub(crate) const SHIFT_LOCAL_MSG: &str = "Adding or subtracting a (date)time delta to a local datetime implicitly ignores DST transitions and other timezone changes. Use the `add` or `subtract` method instead."; pub(crate) const TIMESTAMP_DST_MSG: &str = "Converting from a timestamp with a fixed offset implicitly ignores DST and other timezone changes. To perform a DST-safe conversion, use ZonedDateTime.from_timestamp() instead. Or, if you don't know the timezone and accept potentially incorrect results during DST transitions, pass `ignore_dst=True`. For more information, see whenever.rtfd.io/en/latest/overview.html#dst-safe-arithmetic"; diff --git a/src/lib.rs b/src/lib.rs index 1f403ab..be01402 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -411,7 +411,7 @@ unsafe extern "C" fn module_exec(module: *mut PyObject) -> c_int { state.str_second = PyUnicode_InternFromString(c"second".as_ptr()); state.str_nanosecond = PyUnicode_InternFromString(c"nanosecond".as_ptr()); state.str_nanos = PyUnicode_InternFromString(c"nanos".as_ptr()); - state.str_raise = PyUnicode_InternFromString(c"raise".as_ptr()); + state.str_compatible = PyUnicode_InternFromString(c"compatible".as_ptr()); state.str_tz = PyUnicode_InternFromString(c"tz".as_ptr()); state.str_disambiguate = PyUnicode_InternFromString(c"disambiguate".as_ptr()); state.str_offset = PyUnicode_InternFromString(c"offset".as_ptr()); @@ -604,7 +604,7 @@ unsafe extern "C" fn module_clear(module: *mut PyObject) -> c_int { Py_CLEAR(ptr::addr_of_mut!(state.str_second)); Py_CLEAR(ptr::addr_of_mut!(state.str_nanosecond)); Py_CLEAR(ptr::addr_of_mut!(state.str_nanos)); - Py_CLEAR(ptr::addr_of_mut!(state.str_raise)); + Py_CLEAR(ptr::addr_of_mut!(state.str_compatible)); Py_CLEAR(ptr::addr_of_mut!(state.str_tz)); Py_CLEAR(ptr::addr_of_mut!(state.str_disambiguate)); Py_CLEAR(ptr::addr_of_mut!(state.str_offset)); @@ -694,7 +694,7 @@ struct State { str_second: *mut PyObject, str_nanosecond: *mut PyObject, str_nanos: *mut PyObject, - str_raise: *mut PyObject, + str_compatible: *mut PyObject, str_tz: *mut PyObject, str_disambiguate: *mut PyObject, str_offset: *mut PyObject, diff --git a/src/local_datetime.rs b/src/local_datetime.rs index 28bc80d..c1760dd 100644 --- a/src/local_datetime.rs +++ b/src/local_datetime.rs @@ -725,24 +725,16 @@ unsafe fn assume_tz( let dis = Disambiguate::from_only_kwarg(kwargs, str_disambiguate, "assume_tz")?; let zoneinfo = call1(zoneinfo_type, tz)?; defer_decref!(zoneinfo); - ZonedDateTime::from_local(py_api, date, time, zoneinfo, dis)? - .map_err(|e| match e { - Ambiguity::Fold => py_err!( - exc_repeated, - "{} {} is repeated in the timezone {}", - date, - time, - tz.repr() - ), - Ambiguity::Gap => py_err!( - exc_skipped, - "{} {} is skipped in the timezone {}", - date, - time, - tz.repr() - ), - })? - .to_obj(zoned_datetime_type) + ZonedDateTime::resolve_using_disambiguate( + py_api, + date, + time, + zoneinfo, + dis.unwrap_or(Disambiguate::Compatible), + exc_repeated, + exc_skipped, + )? + .to_obj(zoned_datetime_type) } unsafe fn assume_system_tz( @@ -767,22 +759,15 @@ unsafe fn assume_system_tz( } let dis = Disambiguate::from_only_kwarg(kwargs, str_disambiguate, "assume_system_tz")?; - OffsetDateTime::from_system_tz(py_api, date, time, dis)? - .map_err(|e| match e { - Ambiguity::Fold => py_err!( - exc_repeated, - "{} {} is repeated in the system timezone", - date, - time, - ), - Ambiguity::Gap => py_err!( - exc_skipped, - "{} {} is skipped in the system timezone", - date, - time, - ), - })? - .to_obj(system_datetime_type) + OffsetDateTime::resolve_system_tz_using_disambiguate( + py_api, + date, + time, + dis.unwrap_or(Disambiguate::Compatible), + exc_repeated, + exc_skipped, + )? + .to_obj(system_datetime_type) } unsafe fn replace_date(slf: *mut PyObject, arg: *mut PyObject) -> PyReturn { diff --git a/src/offset_datetime.rs b/src/offset_datetime.rs index 946cf4f..b134772 100644 --- a/src/offset_datetime.rs +++ b/src/offset_datetime.rs @@ -21,9 +21,9 @@ use crate::{ #[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Copy, Clone)] pub(crate) struct OffsetDateTime { - date: Date, - time: Time, - offset_secs: i32, + pub(crate) date: Date, + pub(crate) time: Time, + pub(crate) offset_secs: i32, } pub(crate) const SINGLETONS: &[(&CStr, OffsetDateTime); 0] = &[]; @@ -50,22 +50,6 @@ impl OffsetDateTime { }) } - pub(crate) const fn offset_secs(self) -> i32 { - self.offset_secs - } - - pub(crate) const fn time(self) -> Time { - self.time - } - - pub(crate) const fn date(self) -> Date { - self.date - } - - pub(crate) const fn as_tuple(self) -> (Date, Time, i32) { - (self.date, self.time, self.offset_secs) - } - pub(crate) const fn instant(self) -> Instant { Instant::from_datetime(self.date, self.time).shift_secs_unchecked(-self.offset_secs as i64) } diff --git a/src/system_datetime.rs b/src/system_datetime.rs index b61e7cd..07cf514 100644 --- a/src/system_datetime.rs +++ b/src/system_datetime.rs @@ -1,4 +1,4 @@ -use core::ffi::{c_int, c_long, c_void, CStr}; +use core::ffi::{c_int, c_void, CStr}; use core::{mem, ptr::null_mut as NULL}; use pyo3_ffi::*; @@ -21,36 +21,98 @@ use crate::{ pub(crate) const SINGLETONS: &[(&CStr, OffsetDateTime); 0] = &[]; impl OffsetDateTime { - #[inline] - pub(crate) unsafe fn from_system_tz( + pub(crate) unsafe fn resolve_system_tz( + py_api: &PyDateTime_CAPI, + date: Date, + time: Time, + dis: Option, + preferred_offset: i32, + exc_repeated: *mut PyObject, + exc_skipped: *mut PyObject, + ) -> PyResult { + match dis { + Some(dis) => Self::resolve_system_tz_using_disambiguate( + py_api, + date, + time, + dis, + exc_repeated, + exc_skipped, + ), + None => Self::resolve_system_tz_using_offset(py_api, date, time, preferred_offset), + } + } + + pub(crate) unsafe fn resolve_system_tz_using_disambiguate( py_api: &PyDateTime_CAPI, date: Date, time: Time, dis: Disambiguate, - ) -> PyResult> { + exc_repeated: *mut PyObject, + exc_skipped: *mut PyObject, + ) -> PyResult { use OffsetResult::*; Ok(match OffsetResult::for_system_tz(py_api, date, time)? { - Unambiguous(offset_secs) => Ok(OffsetDateTime::new_unchecked(date, time, offset_secs)), - Fold(offset0, offset1) => match dis { - Disambiguate::Compatible | Disambiguate::Earlier => Ok(offset0), - Disambiguate::Later => Ok(offset1), - Disambiguate::Raise => Err(Ambiguity::Fold), - } - .map(|offset_secs| OffsetDateTime::new_unchecked(date, time, offset_secs)), - Gap(offset0, offset1) => match dis { - Disambiguate::Compatible | Disambiguate::Later => Ok((offset1, offset1 - offset0)), - Disambiguate::Earlier => Ok((offset0, offset0 - offset1)), - Disambiguate::Raise => Err(Ambiguity::Gap), + Unambiguous(offset_secs) => OffsetDateTime::new_unchecked(date, time, offset_secs), + Fold(offset0, offset1) => { + let offset = match dis { + Disambiguate::Compatible | Disambiguate::Earlier => offset0, + Disambiguate::Later => offset1, + Disambiguate::Raise => Err(py_err!( + exc_repeated, + "{} {} is repeated in the system timezone", + date, + time + ))?, + }; + OffsetDateTime::new_unchecked(date, time, offset) } - .map(|(offset_secs, shift)| { + Gap(offset0, offset1) => { + let (offset_secs, shift) = match dis { + Disambiguate::Compatible | Disambiguate::Later => (offset1, offset1 - offset0), + Disambiguate::Earlier => (offset0, offset0 - offset1), + Disambiguate::Raise => Err(py_err!( + exc_skipped, + "{} {} is skipped in the system timezone", + date, + time + ))?, + }; DateTime { date, time } .small_shift_unchecked(shift) .with_offset_unchecked(offset_secs) - }), + } }) } - #[inline] + unsafe fn resolve_system_tz_using_offset( + py_api: &PyDateTime_CAPI, + date: Date, + time: Time, + offset: i32, + ) -> PyResult { + use OffsetResult::*; + match OffsetResult::for_system_tz(py_api, date, time)? { + Unambiguous(offset_secs) => OffsetDateTime::new(date, time, offset_secs), + Fold(offset0, offset1) => OffsetDateTime::new( + date, + time, + if offset == offset1 { offset1 } else { offset0 }, + ), + Gap(offset0, offset1) => { + let (offset_secs, shift) = if offset == offset0 { + (offset0, offset0 - offset1) + } else { + (offset1, offset1 - offset0) + }; + DateTime { date, time } + .small_shift_unchecked(shift) + .with_offset(offset_secs) + } + } + .ok_or_value_err("Resulting datetime is out of range") + } + pub(crate) unsafe fn to_system_tz(self, py_api: &PyDateTime_CAPI) -> PyResult { let dt_original = self.to_py(py_api)?; defer_decref!(dt_original); @@ -67,11 +129,43 @@ impl OffsetDateTime { hour: PyDateTime_DATE_GET_HOUR(dt_new) as u8, minute: PyDateTime_DATE_GET_MINUTE(dt_new) as u8, second: PyDateTime_DATE_GET_SECOND(dt_new) as u8, - nanos: self.time().nanos, + nanos: self.time.nanos, }, offset_from_py_dt(dt_new)?, )) } + + #[allow(clippy::too_many_arguments)] + unsafe fn shift_in_system_tz( + self, + py_api: &PyDateTime_CAPI, + months: i32, + days: i32, + nanos: i128, + dis: Option, + exc_repeated: *mut PyObject, + exc_skipped: *mut PyObject, + ) -> PyResult { + let slf = if months != 0 || days != 0 { + Self::resolve_system_tz( + py_api, + self.date + .shift(0, months, days) + .ok_or_value_err("Resulting date is out of range")?, + self.time, + dis, + self.offset_secs, + exc_repeated, + exc_skipped, + )? + } else { + self + }; + slf.instant() + .shift(nanos) + .ok_or_value_err("Result is out of range")? + .to_system_tz(py_api) + } } impl Instant { @@ -103,17 +197,17 @@ unsafe fn __new__(cls: *mut PyTypeObject, args: *mut PyObject, kwargs: *mut PyOb py_api, exc_repeated, exc_skipped, - str_raise, + str_compatible, .. } = State::for_type(cls); - let mut year: c_long = 0; - let mut month: c_long = 0; - let mut day: c_long = 0; - let mut hour: c_long = 0; - let mut minute: c_long = 0; - let mut second: c_long = 0; - let mut nanos: c_long = 0; - let mut disambiguate: *mut PyObject = str_raise; + let mut year = 0; + let mut month = 0; + let mut day = 0; + let mut hour = 0; + let mut minute = 0; + let mut second = 0; + let mut nanos = 0; + let mut disambiguate: *mut PyObject = str_compatible; // FUTURE: parse them manually, which is more efficient if PyArg_ParseTupleAndKeywords( @@ -147,27 +241,30 @@ unsafe fn __new__(cls: *mut PyTypeObject, args: *mut PyObject, kwargs: *mut PyOb let date = Date::from_longs(year, month, day).ok_or_value_err("Invalid date")?; let time = Time::from_longs(hour, minute, second, nanos).ok_or_value_err("Invalid time")?; let dis = Disambiguate::from_py(disambiguate)?; - OffsetDateTime::from_system_tz(py_api, date, time, dis)? - .map_err(|e| match e { - Ambiguity::Fold => py_err!( - exc_repeated, - "{} {} is repeated in the system timezone", - date, - time - ), - Ambiguity::Gap => py_err!( - exc_skipped, - "{} {} is skipped in the system timezone", - date, - time - ), - })? - .to_obj(cls) + OffsetDateTime::resolve_system_tz_using_disambiguate( + py_api, + date, + time, + dis, + exc_repeated, + exc_skipped, + )? + .to_obj(cls) } unsafe fn __repr__(slf: *mut PyObject) -> PyReturn { - let (date, time, offset) = OffsetDateTime::extract(slf).as_tuple(); - format!("SystemDateTime({} {}{})", date, time, offset_fmt(offset)).to_py() + let OffsetDateTime { + date, + time, + offset_secs, + } = OffsetDateTime::extract(slf); + format!( + "SystemDateTime({} {}{})", + date, + time, + offset_fmt(offset_secs) + ) + .to_py() } unsafe fn __str__(slf: *mut PyObject) -> PyReturn { @@ -202,7 +299,7 @@ unsafe fn __richcmp__(a_obj: *mut PyObject, b_obj: *mut PyObject, op: c_int) -> } #[inline] -unsafe fn _shift(obj_a: *mut PyObject, obj_b: *mut PyObject, negate: bool) -> PyReturn { +unsafe fn _shift_operator(obj_a: *mut PyObject, obj_b: *mut PyObject, negate: bool) -> PyReturn { debug_assert_eq!( PyType_GetModule(Py_TYPE(obj_a)), PyType_GetModule(Py_TYPE(obj_b)) @@ -214,29 +311,43 @@ unsafe fn _shift(obj_a: *mut PyObject, obj_b: *mut PyObject, negate: bool) -> Py date_delta_type, datetime_delta_type, py_api, + exc_repeated, + exc_skipped, .. } = State::for_type(type_a); + + let odt = OffsetDateTime::extract(obj_a); + let mut months = 0; + let mut days = 0; + let mut nanos = 0; + if type_b == time_delta_type { - let odt = OffsetDateTime::extract(obj_a); - let mut delta = TimeDelta::extract(obj_b); - if negate { - delta = -delta; - }; - odt.instant() - .shift(delta.total_nanos()) - .ok_or_value_err("Resulting datetime is out of range")? - .to_system_tz(py_api)? - .to_obj(type_a) - } else if type_b == date_delta_type || type_b == datetime_delta_type { - Err(type_err!(doc::SHIFT_OPERATOR_CALENDAR_ZONED_MSG))? + nanos = TimeDelta::extract(obj_b).total_nanos(); + } else if type_b == date_delta_type { + let dd = DateDelta::extract(obj_b); + months = dd.months; + days = dd.days; + } else if type_b == datetime_delta_type { + let dtd = DateTimeDelta::extract(obj_b); + months = dtd.ddelta.months; + days = dtd.ddelta.days; + nanos = dtd.tdelta.total_nanos(); } else { - Ok(newref(Py_NotImplemented())) - } + return Ok(newref(Py_NotImplemented())); + }; + if negate { + months = -months; + days = -days; + nanos = -nanos; + }; + + odt.shift_in_system_tz(py_api, months, days, nanos, None, exc_repeated, exc_skipped)? + .to_obj(type_a) } unsafe fn __add__(obj_a: *mut PyObject, obj_b: *mut PyObject) -> PyReturn { if PyType_GetModule(Py_TYPE(obj_a)) == PyType_GetModule(Py_TYPE(obj_b)) { - _shift(obj_a, obj_b, false) + _shift_operator(obj_a, obj_b, false) } else { Ok(newref(Py_NotImplemented())) } @@ -266,7 +377,7 @@ unsafe fn __sub__(obj_a: *mut PyObject, obj_b: *mut PyObject) -> PyReturn { } else if type_b == State::for_mod(mod_a).offset_datetime_type { OffsetDateTime::extract(obj_b).instant() } else { - return _shift(obj_a, obj_b, true); + return _shift_operator(obj_a, obj_b, true); }; debug_assert_eq!(type_a, State::for_type(type_a).system_datetime_type); (OffsetDateTime::extract(obj_a).instant(), inst_b) @@ -352,13 +463,13 @@ unsafe fn py_datetime(slf: *mut PyObject, _: *mut PyObject) -> PyReturn { unsafe fn date(slf: *mut PyObject, _: *mut PyObject) -> PyReturn { OffsetDateTime::extract(slf) - .date() + .date .to_obj(State::for_obj(slf).date_type) } unsafe fn time(slf: *mut PyObject, _: *mut PyObject) -> PyReturn { OffsetDateTime::extract(slf) - .time() + .time .to_obj(State::for_obj(slf).time_type) } @@ -385,22 +496,18 @@ unsafe fn replace_date( }; if Py_TYPE(arg) == date_type { - OffsetDateTime::from_system_tz( + let OffsetDateTime { + time, offset_secs, .. + } = OffsetDateTime::extract(slf); + OffsetDateTime::resolve_system_tz( py_api, Date::extract(arg), - OffsetDateTime::extract(slf).time(), + time, Disambiguate::from_only_kwarg(kwargs, str_disambiguate, "replace_date")?, + offset_secs, + exc_repeated, + exc_skipped, )? - .map_err(|e| match e { - Ambiguity::Fold => py_err!( - exc_repeated, - "The new datetime is repeated in the current timezone" - ), - Ambiguity::Gap => py_err!( - exc_skipped, - "The new datetime is skipped in the current timezone" - ), - })? .to_obj(cls) } else { Err(type_err!("date must be a Date instance")) @@ -430,22 +537,18 @@ unsafe fn replace_time( }; if Py_TYPE(arg) == time_type { - OffsetDateTime::from_system_tz( + let OffsetDateTime { + date, offset_secs, .. + } = OffsetDateTime::extract(slf); + OffsetDateTime::resolve_system_tz( py_api, - OffsetDateTime::extract(slf).date(), + date, Time::extract(arg), Disambiguate::from_only_kwarg(kwargs, str_disambiguate, "replace_time")?, + offset_secs, + exc_repeated, + exc_skipped, )? - .map_err(|e| match e { - Ambiguity::Fold => py_err!( - exc_repeated, - "The new datetime is repeated in the current timezone" - ), - Ambiguity::Gap => py_err!( - exc_skipped, - "The new datetime is skipped in the current timezone" - ), - })? .to_obj(cls) } else { Err(type_err!("time must be a Time instance")) @@ -466,7 +569,11 @@ unsafe fn replace( Err(type_err!("replace() takes no positional arguments"))? } let state = State::for_type(cls); - let (date, time, _) = OffsetDateTime::extract(slf).as_tuple(); + let OffsetDateTime { + date, + time, + offset_secs, + } = OffsetDateTime::extract(slf); let mut year = date.year.into(); let mut month = date.month.into(); let mut day = date.day.into(); @@ -499,26 +606,15 @@ unsafe fn replace( let date = Date::from_longs(year, month, day).ok_or_value_err("Invalid date")?; let time = Time::from_longs(hour, minute, second, nanos).ok_or_value_err("Invalid time")?; - OffsetDateTime::from_system_tz( + OffsetDateTime::resolve_system_tz( state.py_api, date, time, - dis.ok_or_type_err("replace() requires a 'disambiguate' keyword argument")?, + dis, + offset_secs, + state.exc_repeated, + state.exc_skipped, )? - .map_err(|e| match e { - Ambiguity::Fold => py_err!( - state.exc_repeated, - "{} {} is repeated in the system timezone", - date, - time - ), - Ambiguity::Gap => py_err!( - state.exc_skipped, - "{} {} is skipped in the system timezone", - date, - time - ), - })? .to_obj(cls) } @@ -549,17 +645,18 @@ unsafe fn from_py_datetime(cls: *mut PyObject, dt: *mut PyObject) -> PyReturn { } unsafe fn __reduce__(slf: *mut PyObject, _: *mut PyObject) -> PyReturn { - let ( - Date { year, month, day }, - Time { - hour, - minute, - second, - nanos, - .. - }, + let OffsetDateTime { + date: Date { year, month, day }, + time: + Time { + hour, + minute, + second, + nanos, + .. + }, offset_secs, - ) = OffsetDateTime::extract(slf).as_tuple(); + } = OffsetDateTime::extract(slf); let data = pack![year, month, day, hour, minute, second, nanos, offset_secs]; ( State::for_obj(slf).unpickle_system_datetime, @@ -746,40 +843,16 @@ unsafe fn _shift_method( days = -days; nanos = -nanos; } - // First, shift the calendar units - let odt = if months != 0 || days != 0 { - let odt = OffsetDateTime::extract(slf); - OffsetDateTime::from_system_tz( + OffsetDateTime::extract(slf) + .shift_in_system_tz( state.py_api, - odt.date() - .shift(0, months, days) - .ok_or_value_err("Resulting date is out of range")?, - odt.time(), - dis.ok_or_else(|| { - type_err!( - "{}() requires a 'disambiguate' keyword argument when given calendar units", - fname - ) - })?, + months, + days, + nanos, + dis, + state.exc_repeated, + state.exc_skipped, )? - .map_err(|amb| match amb { - Ambiguity::Fold => py_err!( - state.exc_repeated, - "The resulting datetime is repeated in the system timezone" - ), - Ambiguity::Gap => py_err!( - state.exc_skipped, - "The resulting datetime is skipped in the system timezone" - ), - })? - } else { - OffsetDateTime::extract(slf) - }; - - odt.instant() - .shift(nanos) - .ok_or_value_err("Result is out of range")? - .to_system_tz(state.py_api)? .to_obj(cls) } @@ -860,35 +933,35 @@ static mut METHODS: &[PyMethodDef] = &[ ]; unsafe fn get_year(slf: *mut PyObject) -> PyReturn { - OffsetDateTime::extract(slf).date().year.to_py() + OffsetDateTime::extract(slf).date.year.to_py() } unsafe fn get_month(slf: *mut PyObject) -> PyReturn { - OffsetDateTime::extract(slf).date().month.to_py() + OffsetDateTime::extract(slf).date.month.to_py() } unsafe fn get_day(slf: *mut PyObject) -> PyReturn { - OffsetDateTime::extract(slf).date().day.to_py() + OffsetDateTime::extract(slf).date.day.to_py() } unsafe fn get_hour(slf: *mut PyObject) -> PyReturn { - OffsetDateTime::extract(slf).time().hour.to_py() + OffsetDateTime::extract(slf).time.hour.to_py() } unsafe fn get_minute(slf: *mut PyObject) -> PyReturn { - OffsetDateTime::extract(slf).time().minute.to_py() + OffsetDateTime::extract(slf).time.minute.to_py() } unsafe fn get_second(slf: *mut PyObject) -> PyReturn { - OffsetDateTime::extract(slf).time().second.to_py() + OffsetDateTime::extract(slf).time.second.to_py() } unsafe fn get_nanos(slf: *mut PyObject) -> PyReturn { - OffsetDateTime::extract(slf).time().nanos.to_py() + OffsetDateTime::extract(slf).time.nanos.to_py() } unsafe fn get_offset(slf: *mut PyObject) -> PyReturn { - TimeDelta::from_secs_unchecked(OffsetDateTime::extract(slf).offset_secs() as i64) + TimeDelta::from_secs_unchecked(OffsetDateTime::extract(slf).offset_secs as i64) .to_obj(State::for_obj(slf).time_delta_type) } diff --git a/src/zoned_datetime.rs b/src/zoned_datetime.rs index 57410f2..1caebeb 100644 --- a/src/zoned_datetime.rs +++ b/src/zoned_datetime.rs @@ -48,39 +48,106 @@ impl ZonedDateTime { }) } - pub(crate) unsafe fn from_local( + #[allow(clippy::too_many_arguments)] + pub(crate) unsafe fn resolve( + py_api: &PyDateTime_CAPI, + date: Date, + time: Time, + zoneinfo: *mut PyObject, + dis: Option, + preferred_offset: i32, + exc_repeated: *mut PyObject, + exc_skipped: *mut PyObject, + ) -> PyResult { + match dis { + Some(d) => Self::resolve_using_disambiguate( + py_api, + date, + time, + zoneinfo, + d, + exc_repeated, + exc_skipped, + ), + None => Self::resolve_using_offset(py_api, date, time, zoneinfo, preferred_offset), + } + } + + pub(crate) unsafe fn resolve_using_disambiguate( py_api: &PyDateTime_CAPI, date: Date, time: Time, zoneinfo: *mut PyObject, dis: Disambiguate, - ) -> PyResult> { + exc_repeated: *mut PyObject, + exc_skipped: *mut PyObject, + ) -> PyResult { use Disambiguate::*; use OffsetResult::*; - Ok(Ok( - match OffsetResult::for_tz(py_api, date, time, zoneinfo)? { - Unambiguous(offset_secs) => ZonedDateTime::new(date, time, offset_secs, zoneinfo), - Fold(offset0, offset1) => { - let offset_secs = match dis { - Compatible | Earlier => offset0, - Later => offset1, - Raise => return Ok(Err(Ambiguity::Fold)), - }; - ZonedDateTime::new(date, time, offset_secs, zoneinfo) - } - Gap(offset0, offset1) => { - let (offset_secs, shift) = match dis { - Compatible | Later => (offset1, offset1 - offset0), - Earlier => (offset0, offset0 - offset1), - Raise => return Ok(Err(Ambiguity::Gap)), - }; - DateTime { date, time } - .small_shift_unchecked(shift) - .with_tz(offset_secs, zoneinfo) - } + match OffsetResult::for_tz(py_api, date, time, zoneinfo)? { + Unambiguous(offset_secs) => ZonedDateTime::new(date, time, offset_secs, zoneinfo), + Fold(offset0, offset1) => { + let offset_secs = match dis { + Compatible | Earlier => offset0, + Later => offset1, + Raise => Err(py_err!( + exc_repeated, + "{} {} is repeated in timezone '{}'", + date, + time, + zoneinfo_key(zoneinfo) + ))?, + }; + ZonedDateTime::new(date, time, offset_secs, zoneinfo) } - .ok_or_value_err("Resulting datetime is out of range")?, - )) + Gap(offset0, offset1) => { + let (offset_secs, shift) = match dis { + Compatible | Later => (offset1, offset1 - offset0), + Earlier => (offset0, offset0 - offset1), + Raise => Err(py_err!( + exc_skipped, + "{} {} is skipped in timezone '{}'", + date, + time, + zoneinfo_key(zoneinfo) + ))?, + }; + DateTime { date, time } + .small_shift_unchecked(shift) + .with_tz(offset_secs, zoneinfo) + } + } + .ok_or_value_err("Resulting datetime is out of range") + } + + unsafe fn resolve_using_offset( + py_api: &PyDateTime_CAPI, + date: Date, + time: Time, + zoneinfo: *mut PyObject, + offset: i32, + ) -> PyResult { + use OffsetResult::*; + match OffsetResult::for_tz(py_api, date, time, zoneinfo)? { + Unambiguous(offset_secs) => ZonedDateTime::new(date, time, offset_secs, zoneinfo), + Fold(offset0, offset1) => ZonedDateTime::new( + date, + time, + if offset == offset1 { offset1 } else { offset0 }, + zoneinfo, + ), + Gap(offset0, offset1) => { + let (offset_secs, shift) = if offset == offset0 { + (offset0, offset0 - offset1) + } else { + (offset1, offset1 - offset0) + }; + DateTime { date, time } + .small_shift_unchecked(shift) + .with_tz(offset_secs, zoneinfo) + } + } + .ok_or_value_err("Resulting datetime is out of range") } pub(crate) const fn instant(self) -> Instant { @@ -97,6 +164,46 @@ impl ZonedDateTime { time: self.time, } } + + #[allow(clippy::too_many_arguments)] + pub(crate) unsafe fn shift( + self, + py_api: &PyDateTime_CAPI, + months: i32, + days: i32, + nanos: i128, + dis: Option, + exc_repeated: *mut PyObject, + exc_skipped: *mut PyObject, + ) -> PyResult { + let shifted_by_date = if months != 0 || days != 0 { + let ZonedDateTime { + date, + time, + zoneinfo, + offset_secs, + } = self; + Self::resolve( + py_api, + date.shift(0, months, days) + .ok_or_value_err("Resulting date is out of range")?, + time, + zoneinfo, + dis, + offset_secs, + exc_repeated, + exc_skipped, + )? + } else { + self + }; + + shifted_by_date + .instant() + .shift(nanos) + .ok_or_value_err("Result is out of range")? + .to_tz(py_api, self.zoneinfo) + } } impl DateTime { @@ -193,7 +300,7 @@ unsafe fn __new__(cls: *mut PyTypeObject, args: *mut PyObject, kwargs: *mut PyOb py_api, exc_repeated, exc_skipped, - str_raise, + str_compatible, .. } = State::for_type(cls); let mut year: c_long = 0; @@ -204,7 +311,7 @@ unsafe fn __new__(cls: *mut PyTypeObject, args: *mut PyObject, kwargs: *mut PyOb let mut second: c_long = 0; let mut nanos: c_long = 0; let mut tz: *mut PyObject = NULL(); - let mut disambiguate: *mut PyObject = str_raise; + let mut disambiguate: *mut PyObject = str_compatible; // OPTIMIZE: parse them manually, which is more efficient if PyArg_ParseTupleAndKeywords( @@ -246,24 +353,16 @@ unsafe fn __new__(cls: *mut PyTypeObject, args: *mut PyObject, kwargs: *mut PyOb let date = Date::from_longs(year, month, day).ok_or_value_err("Invalid date")?; let time = Time::from_longs(hour, minute, second, nanos).ok_or_value_err("Invalid time")?; let dis = Disambiguate::from_py(disambiguate)?; - ZonedDateTime::from_local(py_api, date, time, zoneinfo, dis)? - .map_err(|a| match a { - Ambiguity::Fold => py_err!( - exc_repeated, - "{} {} is repeated in timezone {}", - date, - time, - tz.repr() - ), - Ambiguity::Gap => py_err!( - exc_skipped, - "{} {} is skipped in timezone {}", - date, - time, - tz.repr() - ), - })? - .to_obj(cls) + ZonedDateTime::resolve_using_disambiguate( + py_api, + date, + time, + zoneinfo, + dis, + exc_repeated, + exc_skipped, + )? + .to_obj(cls) } unsafe extern "C" fn dealloc(slf: *mut PyObject) { @@ -324,7 +423,7 @@ unsafe extern "C" fn __hash__(slf: *mut PyObject) -> Py_hash_t { } #[inline] -unsafe fn _shift(obj_a: *mut PyObject, obj_b: *mut PyObject, negate: bool) -> PyReturn { +unsafe fn _shift_operator(obj_a: *mut PyObject, obj_b: *mut PyObject, negate: bool) -> PyReturn { debug_assert_eq!( PyType_GetModule(Py_TYPE(obj_a)), PyType_GetModule(Py_TYPE(obj_b)) @@ -336,29 +435,43 @@ unsafe fn _shift(obj_a: *mut PyObject, obj_b: *mut PyObject, negate: bool) -> Py date_delta_type, datetime_delta_type, py_api, + exc_repeated, + exc_skipped, .. } = State::for_type(type_a); + + let zdt = ZonedDateTime::extract(obj_a); + let mut months = 0; + let mut days = 0; + let mut nanos = 0; + if type_b == time_delta_type { - let zdt = ZonedDateTime::extract(obj_a); - let mut delta = TimeDelta::extract(obj_b); - if negate { - delta = -delta; - }; - zdt.instant() - .shift(delta.total_nanos()) - .ok_or_value_err("Resulting datetime is out of range")? - .to_tz(py_api, zdt.zoneinfo)? - .to_obj(type_a) - } else if type_b == date_delta_type || type_b == datetime_delta_type { - Err(type_err!(doc::SHIFT_OPERATOR_CALENDAR_ZONED_MSG))? + nanos = TimeDelta::extract(obj_b).total_nanos(); + } else if type_b == date_delta_type { + let dd = DateDelta::extract(obj_b); + months = dd.months; + days = dd.days; + } else if type_b == datetime_delta_type { + let dtd = DateTimeDelta::extract(obj_b); + months = dtd.ddelta.months; + days = dtd.ddelta.days; + nanos = dtd.tdelta.total_nanos(); } else { - Ok(newref(Py_NotImplemented())) - } + return Ok(newref(Py_NotImplemented())); + }; + if negate { + months = -months; + days = -days; + nanos = -nanos; + }; + + zdt.shift(py_api, months, days, nanos, None, exc_repeated, exc_skipped)? + .to_obj(type_a) } unsafe fn __add__(slf: *mut PyObject, arg: *mut PyObject) -> PyReturn { if PyType_GetModule(Py_TYPE(slf)) == PyType_GetModule(Py_TYPE(arg)) { - _shift(slf, arg, false) + _shift_operator(slf, arg, false) } else { Ok(newref(Py_NotImplemented())) } @@ -388,7 +501,7 @@ unsafe fn __sub__(obj_a: *mut PyObject, obj_b: *mut PyObject) -> PyReturn { { OffsetDateTime::extract(obj_b).instant() } else { - return _shift(obj_a, obj_b, true); + return _shift_operator(obj_a, obj_b, true); }; debug_assert_eq!(type_a, State::for_type(type_a).zoned_datetime_type); (ZonedDateTime::extract(obj_a).instant(), inst_b) @@ -602,20 +715,24 @@ unsafe fn replace_date( }; let dis = Disambiguate::from_only_kwarg(kwargs, str_disambiguate, "replace_date")?; - let ZonedDateTime { time, zoneinfo, .. } = ZonedDateTime::extract(slf); + let ZonedDateTime { + time, + zoneinfo, + offset_secs, + .. + } = ZonedDateTime::extract(slf); if Py_TYPE(arg) == date_type { - ZonedDateTime::from_local(py_api, Date::extract(arg), time, zoneinfo, dis)? - .map_err(|a| match a { - Ambiguity::Fold => py_err!( - exc_repeated, - "The new date is repeated in the current timezone" - ), - Ambiguity::Gap => py_err!( - exc_skipped, - "The new date is skipped in the current timezone" - ), - })? - .to_obj(cls) + ZonedDateTime::resolve( + py_api, + Date::extract(arg), + time, + zoneinfo, + dis, + offset_secs, + exc_repeated, + exc_skipped, + )? + .to_obj(cls) } else { Err(type_err!("date must be a whenever.Date instance")) } @@ -644,20 +761,24 @@ unsafe fn replace_time( }; let dis = Disambiguate::from_only_kwarg(kwargs, str_disambiguate, "replace_time")?; - let ZonedDateTime { date, zoneinfo, .. } = ZonedDateTime::extract(slf); + let ZonedDateTime { + date, + zoneinfo, + offset_secs, + .. + } = ZonedDateTime::extract(slf); if Py_TYPE(arg) == time_type { - ZonedDateTime::from_local(py_api, date, Time::extract(arg), zoneinfo, dis)? - .map_err(|a| match a { - Ambiguity::Fold => py_err!( - exc_repeated, - "The new time is repeated in the current timezone" - ), - Ambiguity::Gap => py_err!( - exc_skipped, - "The new time is skipped in the current timezone" - ), - })? - .to_obj(cls) + ZonedDateTime::resolve( + py_api, + date, + Time::extract(arg), + zoneinfo, + dis, + offset_secs, + exc_repeated, + exc_skipped, + )? + .to_obj(cls) } else { Err(type_err!("time must be a whenever.Time instance")) } @@ -681,7 +802,7 @@ unsafe fn replace( date, time, mut zoneinfo, - .. + offset_secs, } = ZonedDateTime::extract(slf); let mut year = date.year.into(); let mut month = date.month.into(); @@ -694,8 +815,12 @@ unsafe fn replace( handle_kwargs("replace", kwargs, |key, value, eq| { if eq(key, state.str_tz) { - zoneinfo = call1(state.zoneinfo_type, value)?; - defer_decref!(zoneinfo); + let zoneinfo_new = call1(state.zoneinfo_type, value)?; + if (zoneinfo_new as *mut _) != zoneinfo { + dis.get_or_insert(Disambiguate::Compatible); + }; + defer_decref!(zoneinfo_new); + zoneinfo = zoneinfo_new; } else if eq(key, state.str_disambiguate) { dis = Some(Disambiguate::from_py(value)?); } else { @@ -718,29 +843,16 @@ unsafe fn replace( let date = Date::from_longs(year, month, day).ok_or_value_err("Invalid date")?; let time = Time::from_longs(hour, minute, second, nanos).ok_or_value_err("Invalid time")?; - ZonedDateTime::from_local( + ZonedDateTime::resolve( state.py_api, date, time, zoneinfo, - dis.ok_or_type_err("disambiguate keyword argument is required")?, + dis, + offset_secs, + state.exc_repeated, + state.exc_skipped, )? - .map_err(|a| match a { - Ambiguity::Fold => py_err!( - state.exc_repeated, - "{} {} is repeated in timezone '{}'", - date, - time, - zoneinfo_key(zoneinfo) - ), - Ambiguity::Gap => py_err!( - state.exc_skipped, - "{} {} is skipped in timezone '{}'", - date, - time, - zoneinfo_key(zoneinfo) - ), - })? .to_obj(cls) } @@ -1152,10 +1264,10 @@ unsafe fn _shift_method( months = dd.months; days = dd.days; } else if Py_TYPE(arg) == state.datetime_delta_type { - let dt = DateTimeDelta::extract(arg); - months = dt.ddelta.months; - days = dt.ddelta.days; - nanos = dt.tdelta.total_nanos(); + let dtd = DateTimeDelta::extract(arg); + months = dtd.ddelta.months; + days = dtd.ddelta.days; + nanos = dtd.tdelta.total_nanos(); } else { Err(type_err!("{}() argument must be a delta", fname))? } @@ -1181,47 +1293,17 @@ unsafe fn _shift_method( days = -days; nanos = -nanos; } - // First, shift the calendar units - let zdt = if months != 0 || days != 0 { - let ZonedDateTime { - date, - time, - zoneinfo, - .. - } = ZonedDateTime::extract(slf); - ZonedDateTime::from_local( + + ZonedDateTime::extract(slf) + .shift( state.py_api, - date.shift(0, months, days) - .ok_or_value_err("Resulting date is out of range")?, - time, - zoneinfo, - dis.ok_or_else(|| { - type_err!( - "{}() requires a 'disambiguate' keyword argument when given calendar units", - fname - ) - })?, + months, + days, + nanos, + dis, + state.exc_repeated, + state.exc_skipped, )? - .map_err(|amb| match amb { - Ambiguity::Fold => py_err!( - state.exc_repeated, - "The resulting datetime is repeated in tz {}", - zoneinfo_key(zoneinfo) - ), - Ambiguity::Gap => py_err!( - state.exc_skipped, - "The resulting datetime is skipped in tz {}", - zoneinfo_key(zoneinfo) - ), - })? - } else { - ZonedDateTime::extract(slf) - }; - - zdt.instant() - .shift(nanos) - .ok_or_value_err("Result is out of range")? - .to_tz(state.py_api, zdt.zoneinfo)? .to_obj(cls) } diff --git a/tests/test_local_datetime.py b/tests/test_local_datetime.py index dec1058..77bc8ee 100644 --- a/tests/test_local_datetime.py +++ b/tests/test_local_datetime.py @@ -79,17 +79,13 @@ def test_assume_fixed_offset(): ) -class TestAssumeZoned: +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") - # disambiguate is required - with pytest.raises(TypeError, match="disambiguat"): - d.assume_tz("Asia/Tokyo") # type: ignore[call-arg] - def test_ambiguous(self): d = LocalDateTime(2023, 10, 29, 2, 15) @@ -127,10 +123,6 @@ def test_typical(self): disambiguate="raise" ) == SystemDateTime(2020, 8, 15, 23) - # disambiguate is required - with pytest.raises(TypeError, match="disambiguat"): - LocalDateTime(2020, 8, 15, 23).assume_system_tz() # type: ignore[call-arg] - @system_tz_ams() def test_ambiguous(self): d = LocalDateTime(2023, 10, 29, 2, 15) diff --git a/tests/test_system_datetime.py b/tests/test_system_datetime.py index f9eb6af..7abf279 100644 --- a/tests/test_system_datetime.py +++ b/tests/test_system_datetime.py @@ -22,7 +22,6 @@ hours, microseconds, minutes, - months, seconds, weeks, years, @@ -73,6 +72,10 @@ def test_repeated(self): "hour": 2, "minute": 15, } + assert SystemDateTime(**kwargs, disambiguate="compatible").exact_eq( + SystemDateTime(**kwargs) + ) + d = SystemDateTime(**kwargs, disambiguate="earlier") assert d < SystemDateTime(**kwargs, disambiguate="later") @@ -82,11 +85,8 @@ def test_repeated(self): ): SystemDateTime(2023, 10, 29, 2, 15, disambiguate="raise") - with pytest.raises(RepeatedTime): - SystemDateTime(2023, 10, 29, 2, 15) - @system_tz_ams() - def test_nonexistent(self): + def test_skipped(self): kwargs: dict[str, Any] = { "year": 2023, "month": 3, @@ -94,11 +94,10 @@ def test_nonexistent(self): "hour": 2, "minute": 30, } - with pytest.raises( - SkippedTime, - match="2023-03-26 02:30:00 is skipped in the system timezone", - ): + + assert SystemDateTime(**kwargs, disambiguate="compatible").exact_eq( SystemDateTime(**kwargs) + ) with pytest.raises( SkippedTime, @@ -130,7 +129,7 @@ def test_bounds_min(self): @system_tz_nyc() def test_bounds_max(self): with pytest.raises((ValueError, OverflowError), match="range|year"): - SystemDateTime(9999, 12, 31) + SystemDateTime(9999, 12, 31, 23) class TestInstant: @@ -932,25 +931,25 @@ class TestReplace: @system_tz_ams() def test_basics(self): d = SystemDateTime(2020, 8, 15, 23, 12, 9, nanosecond=987_654_321) - assert d.replace(year=2021, disambiguate="raise").exact_eq( + assert d.replace(year=2021).exact_eq( SystemDateTime(2021, 8, 15, 23, 12, 9, nanosecond=987_654_321) ) - assert d.replace(month=9, disambiguate="raise").exact_eq( + assert d.replace(month=9).exact_eq( SystemDateTime(2020, 9, 15, 23, 12, 9, nanosecond=987_654_321) ) - assert d.replace(day=16, disambiguate="raise").exact_eq( + assert d.replace(day=16).exact_eq( SystemDateTime(2020, 8, 16, 23, 12, 9, nanosecond=987_654_321) ) - assert d.replace(hour=1, disambiguate="raise").exact_eq( + assert d.replace(hour=1).exact_eq( SystemDateTime(2020, 8, 15, 1, 12, 9, nanosecond=987_654_321) ) - assert d.replace(minute=1, disambiguate="raise").exact_eq( + assert d.replace(minute=1).exact_eq( SystemDateTime(2020, 8, 15, 23, 1, 9, nanosecond=987_654_321) ) - assert d.replace(second=1, disambiguate="raise").exact_eq( + assert d.replace(second=1).exact_eq( SystemDateTime(2020, 8, 15, 23, 12, 1, nanosecond=987_654_321) ) - assert d.replace(nanosecond=1, disambiguate="raise").exact_eq( + assert d.replace(nanosecond=1).exact_eq( SystemDateTime(2020, 8, 15, 23, 12, 9, nanosecond=1) ) @@ -963,18 +962,10 @@ def test_invalid(self): with pytest.raises(TypeError, match="foo"): d.replace(foo=1, disambiguate="compatible") # type: ignore[call-arg] - # disambiguate is required - with pytest.raises(TypeError, match="disambiguat"): - d.replace(hour=1) # type: ignore[call-arg] - @system_tz_ams() - def test_disambiguate(self): + def test_repeated(self): d = SystemDateTime(2023, 10, 29, 2, 15, 30, disambiguate="earlier") - with pytest.raises( - RepeatedTime, - match="2023-10-29 02:15:30 is repeated in the system timezone", - ): - d.replace(disambiguate="raise") + d_later = SystemDateTime(2023, 10, 29, 2, 15, 30, disambiguate="later") with pytest.raises( RepeatedTime, match="2023-10-29 02:15:30 is repeated in the system timezone", @@ -985,16 +976,48 @@ def test_disambiguate(self): SystemDateTime(2023, 10, 29, 2, 15, 30, disambiguate="later") ) assert d.replace(disambiguate="earlier").exact_eq(d) + assert d.replace().exact_eq(d) + assert d_later.replace().exact_eq(d_later) + + # very rare case where offset cannot be reused + with system_tz_nyc(): + assert d.replace(month=11, day=5).exact_eq( + SystemDateTime( + 2023, 11, 5, 2, 15, 30, disambiguate="compatible" + ) + ) + assert d_later.replace(month=11, day=5).exact_eq( + SystemDateTime( + 2023, 11, 5, 2, 15, 30, disambiguate="compatible" + ) + ) @system_tz_ams() - def test_nonexistent(self): + def test_skipped(self): d = SystemDateTime(2023, 3, 26, 1, 15, 30) + d_later = SystemDateTime(2023, 3, 26, 3, 15, 30) with pytest.raises( SkippedTime, match="2023-03-26 02:15:30 is skipped in the system timezone", ): d.replace(hour=2, disambiguate="raise") + assert d.replace(hour=2).exact_eq( + SystemDateTime(2023, 3, 26, 2, 15, 30, disambiguate="earlier") + ) + assert d_later.replace(hour=2).exact_eq( + SystemDateTime(2023, 3, 26, 2, 15, 30, disambiguate="later") + ) + + # very rare case where offset cannot be reused + with system_tz_nyc(): + assert d.replace(day=12, hour=2).exact_eq( + SystemDateTime(2023, 3, 12, 3, 15, 30) + ) + assert d_later.replace(day=12, hour=2).exact_eq( + SystemDateTime(2023, 3, 12, 3, 15, 30) + ) + @system_tz_ams() def test_bounds_min(self): d = SystemDateTime(2020, 8, 15, 23, 12, 9) @@ -1137,10 +1160,6 @@ def test_not_implemented(self): with pytest.raises(TypeError): d.add(hours(4), seconds=3) # type: ignore[call-overload] - # other types of delta: recommend use the method - with pytest.raises(TypeError, match="ambigu.*add"): - d + months(1) # type: ignore[operator] - class TestShiftDateUnits: @@ -1148,43 +1167,61 @@ class TestShiftDateUnits: def test_zero(self): d = SystemDateTime(2020, 8, 15, 23, 12, 9, nanosecond=987_654_321) assert d.add(days=0, disambiguate="raise").exact_eq(d) + assert d.add(days=0).exact_eq(d) + assert d.add(weeks=0).exact_eq(d) + assert d.add(months=0).exact_eq(d) + assert d.add(years=0, weeks=0).exact_eq(d) assert d.add().exact_eq(d) + # same with operators + assert d + days(0) == d + assert d + weeks(0) == d + assert d + years(0) == d + # same with subtraction - assert d.subtract(days=0, disambiguate="raise").exact_eq(d) + assert d.subtract(days=0).exact_eq(d) - # disambiguate is required - with pytest.raises(TypeError, match="disambiguat"): - d.add(days=1) # type: ignore[call-overload] + assert d - days(0) == d + assert d - weeks(0) == d + assert d - years(0) == d @system_tz_ams() def test_simple_date(self): d = SystemDateTime(2020, 8, 15, 23, 12, 9, nanosecond=987_654_321) - assert d.add(days=1, disambiguate="raise").exact_eq( - d.replace(day=16, disambiguate="raise") - ) - assert d.add(years=1, weeks=2, days=-2, disambiguate="raise").exact_eq( - d.replace(year=2021, day=27, disambiguate="raise") + assert d.add(days=1).exact_eq(d.replace(day=16)) + assert d.add(years=1, weeks=2, days=-2).exact_eq( + d.replace(year=2021, day=27) ) # same with subtraction - assert d.subtract(days=1, disambiguate="raise").exact_eq( - d.replace(day=14, disambiguate="raise") + assert d.subtract(days=1).exact_eq(d.replace(day=14)) + assert d.subtract(years=1, weeks=2, days=-2).exact_eq( + d.replace(year=2019, day=3) ) - assert d.subtract( - years=1, weeks=2, days=-2, disambiguate="raise" - ).exact_eq(d.replace(year=2019, day=3, disambiguate="raise")) - assert d.add(years=1, weeks=2, days=-2, disambiguate="raise").exact_eq( - d.replace(year=2021, day=27, disambiguate="raise") + assert d.add(years=1, weeks=2, days=-2).exact_eq( + d.replace(year=2021, day=27) ) # same with arg - assert d.add( - years(1) + weeks(2) + days(-2), disambiguate="raise" - ).exact_eq(d.add(years=1, weeks=2, days=-2, disambiguate="raise")) - assert d.add( - years(1) + weeks(2) + hours(2), disambiguate="raise" - ).exact_eq(d.add(years=1, weeks=2, hours=2, disambiguate="raise")) + assert d.add(years(1) + weeks(2) + days(-2)).exact_eq( + d.add(years=1, weeks=2, days=-2) + ) + assert d.add(years(1) + weeks(2) + hours(2)).exact_eq( + 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) + hours(2)) == 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) + hours(2)) == d.subtract( + years=1, weeks=2, hours=2 + ) @system_tz_ams() def test_ambiguity(self): @@ -1197,20 +1234,18 @@ def test_ambiguity(self): 30, disambiguate="later", ) - assert d.add(days=0, disambiguate="raise").exact_eq(d) - assert d.add(days=7, weeks=-1, disambiguate="raise").exact_eq(d) - assert d.add(days=1, disambiguate="raise").exact_eq( - d.replace(day=30, disambiguate="raise") - ) - assert d.add(days=6, disambiguate="raise").exact_eq( - d.replace(month=11, day=4, disambiguate="raise") - ) + assert d.add(days=0).exact_eq(d) + assert d.add(days=7, weeks=-1).exact_eq(d) + assert d.add(days=1).exact_eq(d.replace(day=30)) + assert d.add(days=6).exact_eq(d.replace(month=11, day=4)) assert d.replace(disambiguate="earlier").add(hours=1).exact_eq(d) # transition to another fold assert d.add(years=1, days=-2, disambiguate="compatible").exact_eq( d.replace(year=2024, day=27, disambiguate="earlier") ) + # check operators too + assert d + years(1) - days(2) == d.add(years=1, days=-2) # transition to a gap assert d.add(months=5, days=2, disambiguate="compatible").exact_eq( @@ -1230,11 +1265,9 @@ def test_ambiguity(self): ) # same with subtraction - assert d.subtract(days=0, disambiguate="raise").exact_eq(d) - assert d.subtract(days=7, weeks=-1, disambiguate="raise").exact_eq(d) - assert d.subtract(days=1, disambiguate="raise").exact_eq( - d.replace(day=28, disambiguate="raise") - ) + assert d.subtract(days=0).exact_eq(d) + assert d.subtract(days=7, weeks=-1).exact_eq(d) + assert d.subtract(days=1).exact_eq(d.replace(day=28)) @system_tz_ams() def test_out_of_bounds_min(self): @@ -1312,16 +1345,15 @@ class TestReplaceDate: @system_tz_ams() def test_unambiguous(self): d = SystemDateTime(2020, 8, 15, 14) + assert d.replace_date(Date(2021, 1, 2)) == SystemDateTime( + 2021, 1, 2, 14 + ) assert d.replace_date( Date(2021, 1, 2), disambiguate="raise" ) == SystemDateTime(2021, 1, 2, 14) - # disambiguate is required - with pytest.raises(TypeError, match="disambiguat"): - d.replace_date(Date(2021, 1, 2)) # type: ignore[call-arg] - @system_tz_ams() - def test_ambiguous(self): + def test_repeated(self): d = SystemDateTime(2020, 1, 1, 2, 15, 30) date = Date(2023, 10, 29) with pytest.raises(RepeatedTime): @@ -1336,9 +1368,12 @@ def test_ambiguous(self): assert d.replace_date(date, disambiguate="compatible") == d.replace( year=2023, month=10, day=29, disambiguate="compatible" ) + assert d.replace_date(date).exact_eq( + d.replace_date(date, disambiguate="later") + ) @system_tz_ams() - def test_nonexistent(self): + def test_skipped(self): d = SystemDateTime(2020, 1, 1, 2, 15, 30) date = Date(2023, 3, 26) with pytest.raises(SkippedTime): @@ -1353,6 +1388,9 @@ def test_nonexistent(self): assert d.replace_date(date, disambiguate="compatible") == d.replace( year=2023, month=3, day=26, disambiguate="compatible" ) + assert d.replace_date(date).exact_eq( + d.replace_date(date, disambiguate="earlier") + ) def test_invalid(self): d = SystemDateTime(2020, 8, 15, 14) @@ -1391,13 +1429,12 @@ def test_unambiguous(self): assert d.replace_time( Time(1, 2, 3, nanosecond=4_000), disambiguate="raise" ).exact_eq(SystemDateTime(2020, 8, 15, 1, 2, 3, nanosecond=4_000)) - - # disambiguate is required - with pytest.raises(TypeError, match="disambiguat"): - d.replace_time(Time(1, 2, 3, nanosecond=4_000)) # type: ignore[call-arg] + assert d.replace_time(Time(1, 2, 3, nanosecond=4_000)).exact_eq( + SystemDateTime(2020, 8, 15, 1, 2, 3, nanosecond=4_000) + ) @system_tz_ams() - def test_fold(self): + def test_repeated_time(self): d = SystemDateTime(2023, 10, 29, 0, 15, 30) time = Time(2, 15, 30) with pytest.raises(RepeatedTime): @@ -1412,9 +1449,19 @@ def test_fold(self): assert d.replace_time(time, disambiguate="compatible").exact_eq( d.replace(hour=2, minute=15, second=30, disambiguate="compatible") ) + # default behavior + assert d.replace_time(time).exact_eq( + d.replace(hour=2, minute=15, second=30, disambiguate="earlier") + ) + # For small moves within a fold, keeps the same offset + SystemDateTime(2023, 10, 29, 2, 30, disambiguate="later").replace_time( + time + ).exact_eq( + SystemDateTime(2023, 10, 29, 2, 15, 30, disambiguate="later") + ) @system_tz_ams() - def test_gap(self): + def test_skipped_time(self): d = SystemDateTime(2023, 3, 26, 8, 15) time = Time(2, 15) with pytest.raises(SkippedTime): @@ -1429,6 +1476,10 @@ def test_gap(self): assert d.replace_time(time, disambiguate="compatible").exact_eq( d.replace(hour=2, minute=15, disambiguate="compatible") ) + # default behavior + assert d.replace_time(time).exact_eq( + d.replace(hour=2, minute=15, disambiguate="later") + ) def test_invalid(self): d = SystemDateTime(2020, 8, 15, 14) diff --git a/tests/test_zoned_datetime.py b/tests/test_zoned_datetime.py index 161fe39..b708031 100644 --- a/tests/test_zoned_datetime.py +++ b/tests/test_zoned_datetime.py @@ -28,7 +28,6 @@ hours, milliseconds, minutes, - months, weeks, years, ) @@ -57,7 +56,7 @@ def test_unambiguous(self): assert d.nanosecond == 450 assert d.tz == zone - def test_ambiguous(self): + def test_repeated_time(self): kwargs: dict[str, Any] = dict( year=2023, month=10, @@ -68,11 +67,9 @@ def test_ambiguous(self): tz="Europe/Amsterdam", ) - with pytest.raises( - RepeatedTime, - match="2023-10-29 02:15:30 is repeated in timezone 'Europe/Amsterdam'", - ): - ZonedDateTime(**kwargs) + assert ZonedDateTime(**kwargs) == ZonedDateTime( + **kwargs, disambiguate="compatible" + ) with pytest.raises( RepeatedTime, @@ -156,11 +153,10 @@ def test_skipped(self): second=30, tz="Europe/Amsterdam", ) - with pytest.raises( - SkippedTime, - match="2023-03-26 02:15:30 is skipped in timezone 'Europe/Amsterdam'", - ): - ZonedDateTime(**kwargs) + + assert ZonedDateTime(**kwargs) == ZonedDateTime( + **kwargs, disambiguate="compatible" + ) with pytest.raises( SkippedTime, @@ -212,23 +208,23 @@ def test_local(): ) -class TestWithDate: +class TestReplaceDate: def test_unambiguous(self): d = ZonedDateTime(2020, 8, 15, 14, nanosecond=2, tz="Europe/Amsterdam") - assert d.replace_date(Date(2021, 1, 2), disambiguate="raise").exact_eq( + assert d.replace_date(Date(2021, 1, 2)).exact_eq( ZonedDateTime(2021, 1, 2, 14, nanosecond=2, tz="Europe/Amsterdam") ) - # disambiguation required - with pytest.raises(TypeError, match="disambigua"): - d.replace_date(Date(2023, 10, 29)) # type: ignore[call-arg] - - def test_fold(self): + def test_repeated_time(self): d = ZonedDateTime(2020, 1, 1, 2, 15, 30, tz="Europe/Amsterdam") date = Date(2023, 10, 29) with pytest.raises(RepeatedTime): assert d.replace_date(date, disambiguate="raise") + + assert d.replace_date(date).exact_eq( + d.replace(year=2023, month=10, day=29) + ) assert d.replace_date(date, disambiguate="earlier").exact_eq( d.replace(year=2023, month=10, day=29, disambiguate="earlier") ) @@ -239,13 +235,16 @@ def test_fold(self): d.replace(year=2023, month=10, day=29, disambiguate="compatible") ) - def test_gap(self): + def test_skipped_time(self): d = ZonedDateTime(2020, 1, 1, 2, 15, 30, tz="Europe/Amsterdam") date = Date(2023, 3, 26) with pytest.raises(SkippedTime): assert d.replace_date(date, disambiguate="raise") + assert d.replace_date(date).exact_eq( + d.replace(year=2023, month=3, day=26) + ) assert d.replace_date(date, disambiguate="earlier").exact_eq( d.replace(year=2023, month=3, day=26, disambiguate="earlier") ) @@ -280,28 +279,32 @@ def test_out_of_range_due_to_offset(self): d2.replace_date(Date(9999, 12, 31), disambiguate="compatible") -class TestWithTime: +class TestReplaceTime: def test_unambiguous(self): d = ZonedDateTime(2020, 8, 15, 14, tz="Europe/Amsterdam") - assert d.replace_time( - Time(1, 2, 3, nanosecond=4_000), disambiguate="raise" - ).exact_eq( + assert d.replace_time(Time(1, 2, 3, nanosecond=4_000)).exact_eq( ZonedDateTime( 2020, 8, 15, 1, 2, 3, nanosecond=4_000, tz="Europe/Amsterdam" ) ) - # disambiguation required - with pytest.raises(TypeError, match="disambigua"): - d.replace_time(Time(1, 2, 3, nanosecond=4_000)) # type: ignore[call-arg] - - def test_fold(self): + def test_repeated_time(self): d = ZonedDateTime(2023, 10, 29, 0, 15, 30, tz="Europe/Amsterdam") + d_later = ZonedDateTime(2023, 10, 29, 4, 15, 30, tz="Europe/Amsterdam") time = Time(2, 15, 30) with pytest.raises(RepeatedTime): assert d.replace_time(time, disambiguate="raise") + with pytest.raises(RepeatedTime): + assert d_later.replace_time(time, disambiguate="raise") + + assert d.replace_time(time).exact_eq( + d.replace(hour=2, minute=15, second=30) + ) + assert d_later.replace_time(time).exact_eq( + d_later.replace(hour=2, minute=15, second=30) + ) assert d.replace_time(time, disambiguate="earlier").exact_eq( d.replace(hour=2, minute=15, second=30, disambiguate="earlier") ) @@ -311,13 +314,31 @@ def test_fold(self): assert d.replace_time(time, disambiguate="compatible").exact_eq( d.replace(hour=2, minute=15, second=30, disambiguate="compatible") ) + # For small moves within a fold, keeps the same offset + ZonedDateTime( + 2023, 10, 29, 2, 30, tz="Europe/Amsterdam", disambiguate="later" + ).replace_time(time).exact_eq( + ZonedDateTime( + 2023, + 10, + 29, + 2, + 15, + 30, + tz="Europe/Amsterdam", + disambiguate="later", + ) + ) - def test_gap(self): + def test_skipped_time(self): d = ZonedDateTime(2023, 3, 26, 0, 15, tz="Europe/Amsterdam") time = Time(2, 15) with pytest.raises(SkippedTime): assert d.replace_time(time, disambiguate="raise") + assert d.replace_time(time).exact_eq( + d.replace(hour=2, minute=15, second=0) + ) assert d.replace_time(time, disambiguate="earlier").exact_eq( d.replace(hour=2, minute=15, disambiguate="earlier") ) @@ -1070,6 +1091,10 @@ def test_repr(): repr(ZonedDateTime(2020, 8, 15, 23, 12, tz="Iceland")) == "ZonedDateTime(2020-08-15 23:12:00+00:00[Iceland])" ) + assert ( + repr(ZonedDateTime(2020, 8, 15, 23, 12, tz="UTC")) + == "ZonedDateTime(2020-08-15 23:12:00+00:00[UTC])" + ) class TestComparison: @@ -1443,7 +1468,7 @@ def test_basics(self): d = ZonedDateTime( 2020, 8, 15, 23, 12, 9, nanosecond=987_654, tz="Europe/Amsterdam" ) - assert d.replace(year=2021, disambiguate="raise").exact_eq( + assert d.replace(year=2021).exact_eq( ZonedDateTime( 2021, 8, @@ -1547,11 +1572,7 @@ def test_invalid(self): with pytest.raises(ValueError, match="nano|time"): d.replace(nanosecond=1_000_000_000, disambiguate="compatible") - # disambiguation required - with pytest.raises(TypeError, match="disambigua"): - d.replace(hour=12) # type: ignore[call-arg] - - def test_disambiguate_ambiguous(self): + def test_repeated_time(self): d = ZonedDateTime( 2023, 10, @@ -1562,38 +1583,107 @@ def test_disambiguate_ambiguous(self): tz="Europe/Amsterdam", disambiguate="earlier", ) + d_later = ZonedDateTime( + 2023, + 10, + 29, + 2, + 15, + 30, + tz="Europe/Amsterdam", + disambiguate="later", + ) with pytest.raises( RepeatedTime, match="2023-10-29 02:15:30 is repeated in timezone 'Europe/Amsterdam'", ): d.replace(disambiguate="raise") - assert d.replace(disambiguate="later").exact_eq( + assert d.replace(disambiguate="later").exact_eq(d_later) + assert d.replace(disambiguate="earlier").exact_eq(d) + assert d.replace(disambiguate="compatible").exact_eq(d) + + # earlier offset is reused if possible + assert d.replace().exact_eq(d) + assert d_later.replace().exact_eq(d_later) + assert d.replace(minute=30).exact_eq( + d.replace(minute=30, disambiguate="earlier") + ) + assert d_later.replace(minute=30).exact_eq( + d_later.replace(minute=30, disambiguate="later") + ) + + # Disambiguation may differ depending on whether we change tz + assert d_later.replace(minute=30, tz="Europe/Amsterdam").exact_eq( + d_later.replace(minute=30) + ) + assert not d_later.replace(minute=30, tz="Europe/Paris").exact_eq( + d_later.replace(minute=30) + ) + + # don't reuse offset per se when changing timezone + assert d.replace(hour=3, tz="Europe/Athens").exact_eq( ZonedDateTime( 2023, 10, 29, - 2, + 3, 15, 30, - tz="Europe/Amsterdam", - disambiguate="later", + tz="Europe/Athens", + disambiguate="earlier", + ) + ) + assert d_later.replace(hour=1, tz="Europe/London").exact_eq( + ZonedDateTime( + 2023, + 10, + 29, + 1, + 15, + 30, + tz="Europe/London", + disambiguate="earlier", + ) + ) + assert d.replace(hour=1, tz="Europe/London").exact_eq( + ZonedDateTime( + 2023, + 10, + 29, + 1, + 15, + 30, + tz="Europe/London", + ) + ) + assert d_later.replace(hour=3, tz="Europe/Athens").exact_eq( + ZonedDateTime( + 2023, + 10, + 29, + 3, + 15, + 30, + tz="Europe/Athens", ) ) - assert d.replace(disambiguate="earlier").exact_eq(d) - assert d.replace(disambiguate="compatible").exact_eq(d) - - with pytest.raises(RepeatedTime): - d.replace(disambiguate="raise") - def test_nonexistent(self): + def test_skipped_time(self): d = ZonedDateTime(2023, 3, 26, 1, 15, 30, tz="Europe/Amsterdam") + d_later = ZonedDateTime(2023, 3, 26, 3, 15, 30, tz="Europe/Amsterdam") with pytest.raises( SkippedTime, match="2023-03-26 02:15:30 is skipped in timezone 'Europe/Amsterdam'", ): d.replace(hour=2, disambiguate="raise") + # Disambiguation may differ depending on whether we change tz + assert d.replace( + hour=2, disambiguate="earlier", tz="Europe/Amsterdam" + ).exact_eq(d) + assert not d.replace(hour=2, tz="Europe/Paris").exact_eq(d) + assert d.replace(hour=2, disambiguate="earlier").exact_eq( ZonedDateTime( 2023, @@ -1632,6 +1722,53 @@ def test_nonexistent(self): disambiguate="compatible", ) ) + # Don't per se reuse the offset when changing timezone + assert d.replace(tz="Europe/London").exact_eq( + ZonedDateTime( + 2023, + 3, + 26, + 1, + 15, + 30, + tz="Europe/London", + disambiguate="later", + ) + ) + assert d_later.replace(tz="Europe/Athens").exact_eq( + ZonedDateTime( + 2023, + 3, + 26, + 4, + 15, + 30, + tz="Europe/Athens", + ) + ) + # can't reuse offset + assert d.replace(hour=3, tz="Europe/Athens").exact_eq( + ZonedDateTime( + 2023, + 3, + 26, + 4, + 15, + 30, + tz="Europe/Athens", + ) + ) + assert d_later.replace(hour=1, tz="Europe/London").exact_eq( + ZonedDateTime( + 2023, + 3, + 26, + 2, + 15, + 30, + tz="Europe/London", + ) + ) def test_out_of_range(self): d = ZonedDateTime(1, 1, 1, tz="America/New_York") @@ -1775,10 +1912,6 @@ def test_not_implemented(self): with pytest.raises(TypeError): d.add(hours(34), seconds=3) # type: ignore[call-overload] - # other types of delta: recommend use the method - with pytest.raises(TypeError, match="ambigu.*add"): - d + months(1) # type: ignore[operator] - class TestShiftDateUnits: @@ -1787,14 +1920,24 @@ def test_zero(self): 2020, 8, 15, 23, 12, 9, nanosecond=987_654_321, tz="Asia/Tokyo" ) assert d.add(days=0, disambiguate="raise").exact_eq(d) + assert d.add(days=0).exact_eq(d) + assert d.add(weeks=0).exact_eq(d) + assert d.add(months=0).exact_eq(d) + assert d.add(years=0, weeks=0).exact_eq(d) assert d.add().exact_eq(d) + # same with operators + assert d + days(0) == d + assert d + weeks(0) == d + assert d + years(0) == d + # same with subtraction assert d.subtract(days=0, disambiguate="raise").exact_eq(d) + assert d.subtract(days=0).exact_eq(d) - # disambiguate is required - with pytest.raises(TypeError, match="disambiguat"): - d.add(days=1) # type: ignore[call-overload] + assert d - days(0) == d + assert d - weeks(0) == d + assert d - years(0) == d def test_simple_date(self): d = ZonedDateTime( @@ -1807,31 +1950,40 @@ def test_simple_date(self): nanosecond=987_654_321, tz="Australia/Sydney", ) - assert d.add(days=1, disambiguate="raise").exact_eq( - d.replace(day=16, disambiguate="raise") - ) - assert d.add(years=1, weeks=2, days=-2, disambiguate="raise").exact_eq( - d.replace(year=2021, day=27, disambiguate="raise") + assert d.add(days=1).exact_eq(d.replace(day=16)) + assert d.add(years=1, weeks=2, days=-2).exact_eq( + d.replace(year=2021, day=27) ) # same with subtraction - assert d.subtract(days=1, disambiguate="raise").exact_eq( - d.replace(day=14, disambiguate="raise") + assert d.subtract(days=1).exact_eq(d.replace(day=14)) + assert d.subtract(years=1, weeks=2, days=-2).exact_eq( + d.replace(year=2019, day=3) ) - assert d.subtract( - years=1, weeks=2, days=-2, disambiguate="raise" - ).exact_eq(d.replace(year=2019, day=3, disambiguate="raise")) - assert d.add(years=1, weeks=2, days=-2, disambiguate="raise").exact_eq( - d.replace(year=2021, day=27, disambiguate="raise") + assert d.add(years=1, weeks=2, days=-2).exact_eq( + d.replace(year=2021, day=27) ) # same with arg - assert d.add( - years(1) + weeks(2) + days(-2), disambiguate="raise" - ).exact_eq(d.add(years=1, weeks=2, days=-2, disambiguate="raise")) - assert d.add( - years(1) + weeks(2) + hours(2), disambiguate="raise" - ).exact_eq(d.add(years=1, weeks=2, hours=2, disambiguate="raise")) + assert d.add(years(1) + weeks(2) + days(-2)).exact_eq( + d.add(years=1, weeks=2, days=-2) + ) + assert d.add(years(1) + weeks(2) + hours(2)).exact_eq( + 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) + hours(2)) == 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) + hours(2)) == d.subtract( + years=1, weeks=2, hours=2 + ) def test_ambiguity(self): d = ZonedDateTime( @@ -1844,20 +1996,18 @@ def test_ambiguity(self): disambiguate="later", tz="Europe/Berlin", ) - assert d.add(days=0, disambiguate="raise").exact_eq(d) - assert d.add(days=7, weeks=-1, disambiguate="raise").exact_eq(d) - assert d.add(days=1, disambiguate="raise").exact_eq( - d.replace(day=30, disambiguate="raise") - ) - assert d.add(days=6, disambiguate="raise").exact_eq( - d.replace(month=11, day=4, disambiguate="raise") - ) + assert d.add(days=0).exact_eq(d) + assert d.add(days=7, weeks=-1).exact_eq(d) + assert d.add(days=1).exact_eq(d.replace(day=30)) + assert d.add(days=6).exact_eq(d.replace(month=11, day=4)) assert d.replace(disambiguate="earlier").add(hours=1).exact_eq(d) # transition to another fold assert d.add(years=1, days=-2, disambiguate="compatible").exact_eq( d.replace(year=2024, day=27, disambiguate="earlier") ) + # check operators too + assert d + years(1) - days(2) == d.add(years=1, days=-2) # transition to a gap assert d.add(months=5, days=2, disambiguate="compatible").exact_eq(