Skip to content

Commit

Permalink
Add day duration and start helper methods
Browse files Browse the repository at this point in the history
  • Loading branch information
ariebovenberg committed Dec 26, 2024
1 parent d3bbfe2 commit 5c1dc7b
Show file tree
Hide file tree
Showing 5 changed files with 209 additions and 5 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -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)
-------------------

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
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.16"
version = "0.6.17"
description = "Modern datetime library for Python"
requires-python = ">=3.9"
classifiers = [
Expand Down
34 changes: 33 additions & 1 deletion pysrc/whenever/_pywhenever.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
# - It saves some overhead
from __future__ import annotations

__version__ = "0.6.16"
__version__ = "0.6.17"

import enum
import re
Expand Down Expand Up @@ -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)})"

Expand Down
166 changes: 166 additions & 0 deletions tests/test_zoned_datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import pytest
from hypothesis import given
from hypothesis.strategies import text
from pytest import approx

from whenever import (
Date,
Expand Down Expand Up @@ -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():
Expand Down

0 comments on commit 5c1dc7b

Please sign in to comment.