From 5c1dc7bae7de55015a2c723fd512a9358bbbf34b Mon Sep 17 00:00:00 2001 From: Arie Bovenberg Date: Wed, 25 Dec 2024 23:09:21 +0100 Subject: [PATCH] Add day duration and start helper methods --- CHANGELOG.rst | 6 ++ README.md | 6 +- pyproject.toml | 2 +- pysrc/whenever/_pywhenever.py | 34 ++++++- tests/test_zoned_datetime.py | 166 ++++++++++++++++++++++++++++++++++ 5 files changed, 209 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 405e32d..8eec5ed 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,12 @@ 🚀 Changelog ============ +0.6.17 (2024-12-??) +------------------- + +- Added ``hours_in_day()`` and ``start_of_day()`` methods to ``ZonedDateTime`` + to make it easier to work with edge cases around DST transitions. + 0.6.16 (2024-12-22) ------------------- diff --git a/README.md b/README.md index 6ca2c0b..f5484ac 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ There’s no way to be sure... ✨ Until now! ✨ *Whenever* helps you write **correct** and **type checked** datetime code, -using **well-established concepts** from modern libraries in other languages. +using **well-established concepts** from [modern libraries](#acknowledgements) in other languages. It's also **way faster** than other third-party libraries—and usually the standard library as well. If performance isn't your top priority, a **pure Python** version is available as well. @@ -214,10 +214,10 @@ For more details, see the licenses included in the distribution. ## Acknowledgements -This project is inspired by—and borrows concepts from—the following projects. Check them out! +This project is inspired by—and borrows most concepts from—the following projects. Check them out! -- [Noda Time](https://nodatime.org/) and [Joda Time](https://www.joda.org/joda-time/) - [Temporal](https://tc39.es/proposal-temporal/docs/) +- [Noda Time](https://nodatime.org/) and [Joda Time](https://www.joda.org/joda-time/) The benchmark comparison graph is based on the one from the [Ruff](https://github.com/astral-sh/ruff) project. For timezone data, **Whenever** uses Python's own `zoneinfo` module. diff --git a/pyproject.toml b/pyproject.toml index b5cc756..171e1ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ maintainers = [ {name = "Arie Bovenberg", email = "a.c.bovenberg@gmail.com"}, ] readme = "README.md" -version = "0.6.16" +version = "0.6.17" description = "Modern datetime library for Python" requires-python = ">=3.9" classifiers = [ diff --git a/pysrc/whenever/_pywhenever.py b/pysrc/whenever/_pywhenever.py index 58114c5..35d7912 100644 --- a/pysrc/whenever/_pywhenever.py +++ b/pysrc/whenever/_pywhenever.py @@ -32,7 +32,7 @@ # - It saves some overhead from __future__ import annotations -__version__ = "0.6.16" +__version__ = "0.6.17" import enum import re @@ -4310,6 +4310,38 @@ def is_ambiguous(self) -> bool: # ambiguous datetimes are never equal across timezones return self._py_dt.astimezone(_UTC) != self._py_dt + def hours_in_day(self) -> float: + """The number of hours in the day, accounting for timezone transitions, + e.g. during a DST transition. + + Example + ------- + >>> ZonedDateTime(2020, 8, 15, tz="Europe/London").hours_in_day() + 24 + >>> ZonedDateTime(2023, 10, 29, tz="Europe/Amsterdam").hours_in_day() + 25 + """ + midnight = _datetime.combine( + self._py_dt.date(), _time(), self._py_dt.tzinfo + ) + next_midnight = midnight + _timedelta(days=1) + return ( + next_midnight.astimezone(_UTC) - midnight.astimezone(_UTC) + ) / _timedelta(hours=1) + + def start_of_day(self) -> ZonedDateTime: + """The start of the current calendar day. + + This is almost always at midnight the same day, but may be different + for timezones which transition at—and thus skip over—midnight. + """ + midnight = _datetime.combine( + self._py_dt.date(), _time(), self._py_dt.tzinfo + ) + return ZonedDateTime._from_py_unchecked( + midnight.astimezone(_UTC).astimezone(self._py_dt.tzinfo), 0 + ) + def __repr__(self) -> str: return f"ZonedDateTime({str(self).replace('T', ' ', 1)})" diff --git a/tests/test_zoned_datetime.py b/tests/test_zoned_datetime.py index b708031..75a4ac6 100644 --- a/tests/test_zoned_datetime.py +++ b/tests/test_zoned_datetime.py @@ -12,6 +12,7 @@ import pytest from hypothesis import given from hypothesis.strategies import text +from pytest import approx from whenever import ( Date, @@ -564,6 +565,171 @@ def test_is_ambiguous(): tz="Europe/Amsterdam", disambiguate="earlier", ).is_ambiguous() + # skipped times are shifted into non-ambiguous times + assert not ZonedDateTime( + 2023, + 3, + 26, + 2, + 15, + 30, + tz="Europe/Amsterdam", + ).is_ambiguous() + + +@pytest.mark.parametrize( + "d, expect", + [ + # no special day + (ZonedDateTime(2020, 8, 15, 12, 8, 30, tz="Europe/Amsterdam"), 24), + (ZonedDateTime(1832, 12, 15, 12, 1, 30, tz="UTC"), 24), + # Longer day + (ZonedDateTime(2023, 10, 29, 12, 8, 30, tz="Europe/Amsterdam"), 25), + (ZonedDateTime(2023, 10, 29, tz="Europe/Amsterdam"), 25), + ( + ZonedDateTime(2023, 10, 30, tz="Europe/Amsterdam").subtract( + nanoseconds=1 + ), + 25, + ), + # Shorter day + (ZonedDateTime(2023, 3, 26, 12, 8, 30, tz="Europe/Amsterdam"), 23), + (ZonedDateTime(2023, 3, 26, tz="Europe/Amsterdam"), 23), + ( + ZonedDateTime(2023, 3, 27, tz="Europe/Amsterdam").subtract( + nanoseconds=1 + ), + 23, + ), + # non-hour DST change + (ZonedDateTime(2024, 10, 6, 1, tz="Australia/Lord_Howe"), 23.5), + (ZonedDateTime(2024, 4, 7, 1, tz="Australia/Lord_Howe"), 24.5), + # Non-regular transition + ( + ZonedDateTime(1894, 6, 1, 1, tz="Europe/Zurich"), + approx(23.49611111), + ), + # DST starts at midnight + (ZonedDateTime(2016, 2, 20, tz="America/Sao_Paulo"), 25), + (ZonedDateTime(2016, 2, 21, tz="America/Sao_Paulo"), 24), + (ZonedDateTime(2016, 10, 16, tz="America/Sao_Paulo"), 23), + (ZonedDateTime(2016, 10, 17, tz="America/Sao_Paulo"), 24), + # Samoa skipped a day + (ZonedDateTime(2011, 12, 31, 21, tz="Pacific/Apia"), 24), + (ZonedDateTime(2011, 12, 29, 21, tz="Pacific/Apia"), 24), + # A day that starts twice + ( + ZonedDateTime( + 2016, + 2, + 20, + 23, + 45, + disambiguate="later", + tz="America/Sao_Paulo", + ), + 25, + ), + ( + ZonedDateTime( + 2016, + 2, + 20, + 23, + 45, + disambiguate="earlier", + tz="America/Sao_Paulo", + ), + 25, + ), + ], +) +def test_hours_in_day(d, expect): + assert d.hours_in_day() == expect + + +@pytest.mark.parametrize( + "d, expect", + [ + # no special day + ( + ZonedDateTime(2020, 8, 15, 12, 8, 30, tz="Europe/Amsterdam"), + ZonedDateTime(2020, 8, 15, tz="Europe/Amsterdam"), + ), + ( + ZonedDateTime(1832, 12, 15, 12, 1, 30, tz="UTC"), + ZonedDateTime(1832, 12, 15, tz="UTC"), + ), + # DST at non-midnight + ( + ZonedDateTime(2023, 10, 29, 12, 8, 30, tz="Europe/Amsterdam"), + ZonedDateTime(2023, 10, 29, tz="Europe/Amsterdam"), + ), + ( + ZonedDateTime(2023, 3, 26, 12, 8, 30, tz="Europe/Amsterdam"), + ZonedDateTime(2023, 3, 26, tz="Europe/Amsterdam"), + ), + ( + ZonedDateTime(2024, 4, 7, 1, tz="Australia/Lord_Howe"), + ZonedDateTime(2024, 4, 7, tz="Australia/Lord_Howe"), + ), + # Non-regular transition + ( + ZonedDateTime(1894, 6, 1, 1, tz="Europe/Zurich"), + ZonedDateTime(1894, 6, 1, 0, 30, 14, tz="Europe/Zurich"), + ), + # DST starts at midnight + ( + ZonedDateTime(2016, 2, 20, 8, tz="America/Sao_Paulo"), + ZonedDateTime(2016, 2, 20, tz="America/Sao_Paulo"), + ), + ( + ZonedDateTime(2016, 2, 21, 2, tz="America/Sao_Paulo"), + ZonedDateTime(2016, 2, 21, tz="America/Sao_Paulo"), + ), + ( + ZonedDateTime(2016, 10, 16, 15, tz="America/Sao_Paulo"), + ZonedDateTime(2016, 10, 16, 1, tz="America/Sao_Paulo"), + ), + ( + ZonedDateTime(2016, 10, 17, 19, tz="America/Sao_Paulo"), + ZonedDateTime(2016, 10, 17, tz="America/Sao_Paulo"), + ), + # Samoa skipped a day + ( + ZonedDateTime(2011, 12, 31, 21, tz="Pacific/Apia"), + ZonedDateTime(2011, 12, 31, tz="Pacific/Apia"), + ), + ( + ZonedDateTime(2011, 12, 29, 21, tz="Pacific/Apia"), + ZonedDateTime(2011, 12, 29, tz="Pacific/Apia"), + ), + # Another edge case + ( + ZonedDateTime(2010, 11, 7, 23, tz="America/St_Johns"), + ZonedDateTime( + 2010, 11, 7, tz="America/St_Johns", disambiguate="earlier" + ), + ), + # a day that starts twice + ( + ZonedDateTime( + 2016, + 2, + 20, + 23, + 45, + disambiguate="later", + tz="America/Sao_Paulo", + ), + ZonedDateTime( + 2016, 2, 20, tz="America/Sao_Paulo", disambiguate="raise" + ), + ), + ], +) +def test_start_of_day(d, expect): + assert d.start_of_day() == expect def test_instant():