Skip to content

Commit

Permalink
Make disambiguate argument optional
Browse files Browse the repository at this point in the history
  • Loading branch information
ariebovenberg committed Dec 22, 2024
1 parent b59c6fc commit 9ae7f74
Show file tree
Hide file tree
Showing 17 changed files with 1,160 additions and 892 deletions.
14 changes: 14 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -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)
-------------------

Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 9 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <span style="text-decoration: underline; text-decoration-color: red; text-decoration-style: wavy">red squiggles</span> 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.


<p align="center">
<picture align="center">
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ariebovenberg/whenever/main/benchmarks/comparison/graph-dark.svg">
Expand All @@ -35,9 +33,9 @@ If performance isn't your top priority, a **pure Python** version is available a
<i>RFC3339-parse, normalize, compare to now, shift, and change timezone (1M times)</i>
</p>


<div align="center">


[📖 Docs](https://whenever.readthedocs.io) |
[🐍 PyPI](https://pypi.org/project/whenever/) |
[🐙 GitHub](https://github.com/ariebovenberg/whenever) |
Expand All @@ -48,7 +46,7 @@ If performance isn't your top priority, a **pure Python** version is available a

</div>

> ⚠️ **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!
Expand Down Expand Up @@ -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?

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
104 changes: 51 additions & 53 deletions docs/overview.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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::
Expand Down Expand Up @@ -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::
Expand Down Expand Up @@ -388,9 +388,8 @@ Two common situations arise:

The figure in the Python docs `here <https://peps.python.org/pep-0495/#mind-the-gap>`_ also shows how this "extrapolation" makes sense graphically.

**Whenever** `refuses to guess <https://peps.python.org/pep-0020/>`_
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 |
Expand All @@ -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 |
Expand All @@ -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:
Expand Down Expand Up @@ -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.

Expand All @@ -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.

Expand All @@ -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"
Expand All @@ -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,
Expand All @@ -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 <durations>` for more details
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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)
Expand All @@ -623,15 +622,15 @@ 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]_ |
| minutes, seconds, ... | | | | | |
+-----------------------+---------+---------+---------+----------+---------+

.. [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?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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::
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ maintainers = [
{name = "Arie Bovenberg", email = "[email protected]"},
]
readme = "README.md"
version = "0.6.15"
version = "0.6.16"
description = "Modern datetime library for Python"
requires-python = ">=3.9"
classifiers = [
Expand Down
Loading

0 comments on commit 9ae7f74

Please sign in to comment.