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