From d77af7f5714d73e90cca7f7d03950cfd1db68196 Mon Sep 17 00:00:00 2001 From: Samuel Searles-Bryant Date: Tue, 9 Jul 2024 12:03:57 +0100 Subject: [PATCH 1/9] Add docstring to module This matches the project description. --- src/datetime_tools/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/datetime_tools/__init__.py b/src/datetime_tools/__init__.py index 96ad165..55be7ed 100644 --- a/src/datetime_tools/__init__.py +++ b/src/datetime_tools/__init__.py @@ -1,3 +1,3 @@ """ -TODO Add a description of the package here. +Tools for working with timezone-aware datetimes. """ From a399abacfc7f0abcc28078fbc19fefc72ff34a2d Mon Sep 17 00:00:00 2001 From: Samuel Searles-Bryant Date: Tue, 9 Jul 2024 12:08:59 +0100 Subject: [PATCH 2/9] Format before linting This avoids noisy E501 errors that will be auto-formatted away. --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6de206c..7bbbe8e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,9 +2,9 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.5.1 hooks: + - id: ruff-format - id: ruff args: [--fix, --show-fixes] - - id: ruff-format - repo: https://github.com/samueljsb/sort-lines rev: v0.3.0 hooks: From 9f4c1f031ee6c1887141da7531e73bfb284ed627 Mon Sep 17 00:00:00 2001 From: Samuel Searles-Bryant Date: Tue, 9 Jul 2024 12:36:37 +0100 Subject: [PATCH 3/9] Add an object for managing dates/times in a specific timezone This allows us to provide timezone-aware utilities. This commit adds the methods that create new `datetime` objects. --- src/datetime_tools/__init__.py | 4 ++ src/datetime_tools/_converter.py | 57 ++++++++++++++++++++++++++++ tests/test_converter.py | 65 ++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 src/datetime_tools/_converter.py create mode 100644 tests/test_converter.py diff --git a/src/datetime_tools/__init__.py b/src/datetime_tools/__init__.py index 55be7ed..4c1dce9 100644 --- a/src/datetime_tools/__init__.py +++ b/src/datetime_tools/__init__.py @@ -1,3 +1,7 @@ """ Tools for working with timezone-aware datetimes. """ + +from ._converter import TimezoneConverter + +__all__ = ("TimezoneConverter",) diff --git a/src/datetime_tools/_converter.py b/src/datetime_tools/_converter.py new file mode 100644 index 0000000..53204fc --- /dev/null +++ b/src/datetime_tools/_converter.py @@ -0,0 +1,57 @@ +import datetime as datetime_ +import zoneinfo + + +class TimezoneConverter: + """Manage dates and datetimes in a specific timezone.""" + + def __init__(self, timezone: str) -> None: + self.tzinfo = zoneinfo.ZoneInfo(timezone) + + # Constructors + + def datetime( + self, + year: int, + month: int, + day: int, + hour: int = 0, + minute: int = 0, + second: int = 0, + microsecond: int = 0, + *, + fold: int = 0, + ) -> datetime_.datetime: + """Create a timezone-aware datetime.""" + return datetime_.datetime( + year, + month, + day, + hour, + minute, + second, + microsecond, + fold=fold, + tzinfo=self.tzinfo, + ) + + def combine( + self, date: datetime_.date, time: datetime_.time + ) -> datetime_.datetime: + """Create a timezone-aware datetime from a date and time. + + If the time is timezone-aware, it will be converted to this timezone; + if it is naive, it will be made timezone-aware in this timezone. + """ + inferred_timezone = time.tzinfo or self.tzinfo + return datetime_.datetime.combine( + date, time, tzinfo=inferred_timezone + ).astimezone(self.tzinfo) + + @property + def far_past(self) -> datetime_.datetime: + return datetime_.datetime.min.replace(tzinfo=self.tzinfo) + + @property + def far_future(self) -> datetime_.datetime: + return datetime_.datetime.max.replace(tzinfo=self.tzinfo) diff --git a/tests/test_converter.py b/tests/test_converter.py new file mode 100644 index 0000000..0c4cb24 --- /dev/null +++ b/tests/test_converter.py @@ -0,0 +1,65 @@ +import datetime +import zoneinfo + +from datetime_tools import TimezoneConverter + +# Note [Use Europe/Paris for tests] +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# These tests use the Europe/Paris timezone. This is to make sure that +# localized times cannot be confused with naive times that have had timezone +# info added. If a naive time is assumed to be in UTC, it will be different +# when localized to Europe/Paris, regardless of DST. + + +def test_datetime() -> None: + paris_time = TimezoneConverter("Europe/Paris") + + assert paris_time.datetime(2024, 7, 9, 12, 45, 0) == datetime.datetime( + 2024, 7, 9, 12, 45, 0, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ) + + +def test_combine_naive() -> None: + """Check that a naive time is made timezone-aware.""" + paris_time = TimezoneConverter("Europe/Paris") + + assert paris_time.combine( + datetime.date(2024, 7, 9), datetime.time(12, 45, 0) + ) == datetime.datetime( + 2024, 7, 9, 12, 45, 0, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ) + + +def test_combine_and_convert() -> None: + """Check that a timezone-aware time is converted.""" + paris_time = TimezoneConverter("Europe/Paris") + + assert paris_time.combine( + datetime.date(2024, 7, 9), + datetime.time(12, 45, 0, tzinfo=zoneinfo.ZoneInfo("Europe/London")), + ) == datetime.datetime( + 2024, 7, 9, 13, 45, 0, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ) + + +def test_far_past() -> None: + paris_time = TimezoneConverter("Europe/Paris") + + assert paris_time.far_past == datetime.datetime( + 1, 1, 1, 0, 0, 0, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ) + + +def test_far_future() -> None: + paris_time = TimezoneConverter("Europe/Paris") + + assert paris_time.far_future == datetime.datetime( + 9999, + 12, + 31, + 23, + 59, + 59, + 999999, + tzinfo=zoneinfo.ZoneInfo("Europe/Paris"), + ) From b1b7d6735b8cafdbcf9c415328f736a1b16ad2af Mon Sep 17 00:00:00 2001 From: Samuel Searles-Bryant Date: Tue, 9 Jul 2024 14:42:27 +0100 Subject: [PATCH 4/9] Implement conversion methods This allows the timezone converter object to make naive datetime timezone-aware and to convert between timezones. --- src/datetime_tools/_converter.py | 43 +++++++++++++++++++ tests/test_converter.py | 71 ++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/src/datetime_tools/_converter.py b/src/datetime_tools/_converter.py index 53204fc..57ee29b 100644 --- a/src/datetime_tools/_converter.py +++ b/src/datetime_tools/_converter.py @@ -55,3 +55,46 @@ def far_past(self) -> datetime_.datetime: @property def far_future(self) -> datetime_.datetime: return datetime_.datetime.max.replace(tzinfo=self.tzinfo) + + # Conversions + + class AlreadyAware(Exception): + pass + + def make_aware(self, datetime: datetime_.datetime) -> datetime_.datetime: + """Make a naive datetime timezone-aware in this timezone. + + Raises: + AlreadyAware: The datetime is already timezone-aware. + Use `localize` to convert the time into this timezone. + """ + if datetime.tzinfo: + raise self.AlreadyAware + + return datetime.replace(tzinfo=self.tzinfo) + + class NaiveDatetime(Exception): + pass + + def localize(self, datetime: datetime_.datetime) -> datetime_.datetime: + """Localize a timezone-aware datetime to this timezone. + + Raises: + NaiveDatetime: The datetime is naive, so we do not know which + timezone to localize from. Use `make_aware` to make a naive + datetime timezone-aware. + """ + if not datetime.tzinfo: + raise self.NaiveDatetime + + return datetime.astimezone(self.tzinfo) + + def date(self, datetime: datetime_.datetime) -> datetime_.date: + """Get the date in this timezone at a moment in time. + + Raises: + NaiveDatetime: The datetime is naive, so we do not know which + timezone to localize from. Use `make_aware` to make a naive + datetime timezone-aware. + """ + return self.localize(datetime).date() diff --git a/tests/test_converter.py b/tests/test_converter.py index 0c4cb24..b7f7385 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -1,6 +1,8 @@ import datetime import zoneinfo +import pytest + from datetime_tools import TimezoneConverter # Note [Use Europe/Paris for tests] @@ -63,3 +65,72 @@ def test_far_future() -> None: 999999, tzinfo=zoneinfo.ZoneInfo("Europe/Paris"), ) + + +def test_make_aware() -> None: + paris_time = TimezoneConverter("Europe/Paris") + + assert paris_time.make_aware( + datetime.datetime(2024, 7, 9, 12, 45, 0, tzinfo=None) + ) == datetime.datetime( + 2024, 7, 9, 12, 45, 0, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ) + + +def test_make_aware_requires_naive_datetime() -> None: + paris_time = TimezoneConverter("Europe/Paris") + already_aware = datetime.datetime( + 2024, 7, 9, 12, 45, 0, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ) + + with pytest.raises(paris_time.AlreadyAware): + paris_time.make_aware(already_aware) + + +def test_localize() -> None: + paris_time = TimezoneConverter("Europe/Paris") + + assert paris_time.localize( + datetime.datetime( + 2024, 7, 9, 12, 45, 0, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ) + ) == datetime.datetime( + 2024, 7, 9, 13, 45, 0, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ) + + +def test_localize_requires_aware_datetime() -> None: + paris_time = TimezoneConverter("Europe/Paris") + naive_datetime = datetime.datetime(2024, 7, 9, 12, 45, 0, tzinfo=None) + + with pytest.raises(paris_time.NaiveDatetime): + paris_time.localize(naive_datetime) + + +def test_date() -> None: + paris_time = TimezoneConverter("Europe/Paris") + + assert paris_time.date( + datetime.datetime( + 2024, 7, 9, 12, 45, 0, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ) + ) == datetime.date(2024, 7, 9) + + +def test_date_from_different_timezone() -> None: + paris_time = TimezoneConverter("Europe/Paris") + + # just before midnight in London is after midnight in Paris + assert paris_time.date( + datetime.datetime( + 2024, 7, 8, 23, 30, 0, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ) + ) == datetime.date(2024, 7, 9) + + +def test_date_requires_aware_datetime() -> None: + paris_time = TimezoneConverter("Europe/Paris") + naive_datetime = datetime.datetime(2024, 7, 9, 12, 45, 0, tzinfo=None) + + with pytest.raises(paris_time.NaiveDatetime): + paris_time.date(naive_datetime) From 73bf1f20f6858c06685e25f31cdbbb0226a582b3 Mon Sep 17 00:00:00 2001 From: Samuel Searles-Bryant Date: Tue, 9 Jul 2024 16:29:34 +0100 Subject: [PATCH 5/9] Implement quantization This allows users to round timezone-aware datetimes to a specific resolution. N.B. This differs from the implementation in `xocto` in a few ways: 1. we only implement rounding up and down -- other rounding methods are not used and are unnecessarily complicated; 2. we avoid the `decimal` module because rounding the UNIX timestamp makes all values relative to the UNIX epoch, which is incorrect in many timezones and doesn't account for changing offsets in the period since the epoch. --- .pre-commit-config.yaml | 1 + pyproject.toml | 4 +- requirements.txt | 2 + src/datetime_tools/_converter.py | 54 +++++++++ tests/test_converter.py | 183 +++++++++++++++++++++++++++++++ 5 files changed, 243 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7bbbe8e..2cf8a68 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,3 +29,4 @@ repos: rev: v1.10.1 hooks: - id: mypy + additional_dependencies: [pytest] diff --git a/pyproject.toml b/pyproject.toml index ecf0dbb..cdabf95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,9 @@ description = "Tools for working with timezone-aware datetimes." license.file = "LICENSE" readme = "README.md" requires-python = ">=3.10" -dependencies = [] +dependencies = [ + "typing_extensions", +] classifiers = [ # pragma: alphabetize "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", diff --git a/requirements.txt b/requirements.txt index e0a4bb5..0aa498b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -54,6 +54,8 @@ tox==4.16.0 # tox-uv tox-uv==1.9.1 # via datetime-tools (pyproject.toml) +typing-extensions==4.12.2 + # via datetime-tools (pyproject.toml) uv==0.2.22 # via # datetime-tools (pyproject.toml) diff --git a/src/datetime_tools/_converter.py b/src/datetime_tools/_converter.py index 57ee29b..0795cf6 100644 --- a/src/datetime_tools/_converter.py +++ b/src/datetime_tools/_converter.py @@ -1,5 +1,8 @@ import datetime as datetime_ import zoneinfo +from typing import Literal + +from typing_extensions import assert_never class TimezoneConverter: @@ -98,3 +101,54 @@ def date(self, datetime: datetime_.datetime) -> datetime_.date: datetime timezone-aware. """ return self.localize(datetime).date() + + # Quantize + + ROUND_DOWN: Literal["ROUND_DOWN"] = "ROUND_DOWN" + ROUND_UP: Literal["ROUND_UP"] = "ROUND_UP" + + class ResolutionTooLarge(Exception): + pass + + def quantize( + self, + datetime: datetime_.datetime, + resolution: datetime_.timedelta, + rounding: Literal["ROUND_UP", "ROUND_DOWN"], + ) -> datetime_.datetime: + """'Round' a datetime to some resolution. + + This will truncate the datetime to a whole value of the resolution in + the given timezone. The resolution must not exceed a day (because then + the reference point is ambiguous.) + + Raises: + ResolutionTooLarge: The resolution is too large. + NaiveDatetime: The datetime is naive, so we do not know which + timezone to localize from. Use `make_aware` to make a naive + datetime timezone-aware. + """ + if resolution > datetime_.timedelta(days=1): + raise self.ResolutionTooLarge + + # start with round-down and round-up candidates at the start of the day + # in this timezone + lower_candidate = self.combine( + self.date(datetime), datetime_.time(00, 00, 00) + ) + upper_candidate = lower_candidate + resolution + + # walk forwards in steps of `resolution` until the datetime is inside + # the bounds + while upper_candidate < datetime: + lower_candidate, upper_candidate = ( + upper_candidate, + upper_candidate + resolution, + ) + + if rounding == self.ROUND_DOWN: + return lower_candidate + elif rounding == self.ROUND_UP: + return upper_candidate + else: # pragma: no cover + assert_never(rounding) diff --git a/tests/test_converter.py b/tests/test_converter.py index b7f7385..d080037 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -1,5 +1,6 @@ import datetime import zoneinfo +from typing import Literal import pytest @@ -134,3 +135,185 @@ def test_date_requires_aware_datetime() -> None: with pytest.raises(paris_time.NaiveDatetime): paris_time.date(naive_datetime) + + +_half_hour = datetime.timedelta(minutes=30) +_two_hours = datetime.timedelta(hours=2) +_day = datetime.timedelta(days=1) + + +@pytest.mark.parametrize( + "initial_datetime, resolution, rounding, expected_result", + ( + pytest.param( + datetime.datetime( + 2024, 7, 9, 12, 45, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + _half_hour, + TimezoneConverter.ROUND_DOWN, + datetime.datetime( + 2024, 7, 9, 12, 30, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + id="same timezone, round down 1/2 hour", + ), + pytest.param( + datetime.datetime( + 2024, 7, 9, 12, 45, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + _half_hour, + TimezoneConverter.ROUND_UP, + datetime.datetime( + 2024, 7, 9, 13, 00, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + id="same timezone, round up 1/2 hour", + ), + pytest.param( + datetime.datetime( + 2024, 7, 9, 13, 45, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + _two_hours, + TimezoneConverter.ROUND_DOWN, + datetime.datetime( + 2024, 7, 9, 12, 00, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + id="same timezone, round down 2 hours", + ), + pytest.param( + datetime.datetime( + 2024, 7, 9, 13, 45, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + _two_hours, + TimezoneConverter.ROUND_UP, + datetime.datetime( + 2024, 7, 9, 14, 00, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + id="same timezone, round up 2 hours", + ), + pytest.param( + datetime.datetime( + 2024, 7, 9, 13, 45, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + _day, + TimezoneConverter.ROUND_DOWN, + datetime.datetime( + 2024, 7, 9, 00, 00, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + id="same timezone, round down 1 day", + ), + pytest.param( + datetime.datetime( + 2024, 7, 9, 13, 45, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + _day, + TimezoneConverter.ROUND_UP, + datetime.datetime( + 2024, 7, 10, 00, 00, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + id="same timezone, round up 1 day", + ), + pytest.param( + datetime.datetime( + 2024, 7, 9, 12, 45, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ), + _half_hour, + TimezoneConverter.ROUND_DOWN, + datetime.datetime( + 2024, 7, 9, 13, 30, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + id="different timezone, round down 1/2 hour", + ), + pytest.param( + datetime.datetime( + 2024, 7, 9, 12, 45, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ), + _half_hour, + TimezoneConverter.ROUND_UP, + datetime.datetime( + 2024, 7, 9, 14, 00, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + id="different timezone, round up 1/2 hour", + ), + pytest.param( + datetime.datetime( + 2024, 7, 9, 12, 45, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ), + _two_hours, + TimezoneConverter.ROUND_DOWN, + datetime.datetime( + 2024, 7, 9, 12, 00, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + id="different timezone, round down 2 hours", + ), + pytest.param( + datetime.datetime( + 2024, 7, 9, 13, 45, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ), + _two_hours, + TimezoneConverter.ROUND_UP, + datetime.datetime( + 2024, 7, 9, 16, 00, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + id="different timezone, round up 2 hours", + ), + pytest.param( + datetime.datetime( + 2024, 7, 9, 23, 30, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ), + _day, + TimezoneConverter.ROUND_DOWN, + datetime.datetime( + 2024, 7, 10, 00, 00, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + id="different timezone, round down 1 day", + ), + pytest.param( + datetime.datetime( + 2024, 7, 9, 23, 30, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ), + _day, + TimezoneConverter.ROUND_UP, + datetime.datetime( + 2024, 7, 11, 00, 00, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + id="different timezone, round up 1 day", + ), + ), +) +def test_quantize( + initial_datetime: datetime.datetime, + resolution: datetime.timedelta, + rounding: Literal["ROUND_UP", "ROUND_DOWN"], + expected_result: datetime.datetime, +) -> None: + paris_time = TimezoneConverter("Europe/Paris") + + assert ( + paris_time.quantize(initial_datetime, resolution, rounding) + == expected_result + ) + + +def test_quantize_requires_resolution_less_than_a_day() -> None: + paris_time = TimezoneConverter("Europe/Paris") + some_datetime = datetime.datetime( + 2024, 7, 9, 12, 45, 0, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ) + + with pytest.raises(paris_time.ResolutionTooLarge): + paris_time.quantize( + some_datetime, + datetime.timedelta(hours=25), + rounding=paris_time.ROUND_DOWN, + ) + + +def test_quantize_requires_aware_datetime() -> None: + paris_time = TimezoneConverter("Europe/Paris") + naive_datetime = datetime.datetime(2024, 7, 9, 12, 45, 0, tzinfo=None) + + with pytest.raises(paris_time.NaiveDatetime): + paris_time.quantize( + naive_datetime, + datetime.timedelta(minutes=1), + rounding=paris_time.ROUND_DOWN, + ) From fcabbc9c7886bbb2f9dc953bdae9935367e9ebd1 Mon Sep 17 00:00:00 2001 From: Samuel Searles-Bryant Date: Tue, 9 Jul 2024 18:14:17 +0100 Subject: [PATCH 6/9] Add methods for getting relative dates/times --- .pre-commit-config.yaml | 2 +- pyproject.toml | 1 + src/datetime_tools/_converter.py | 147 ++++++++++++ tests/test_converter.py | 378 +++++++++++++++++++++++++++++++ 4 files changed, 527 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2cf8a68..5a84cc3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,4 +29,4 @@ repos: rev: v1.10.1 hooks: - id: mypy - additional_dependencies: [pytest] + additional_dependencies: [pytest, types-python-dateutil] diff --git a/pyproject.toml b/pyproject.toml index cdabf95..845d683 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ license.file = "LICENSE" readme = "README.md" requires-python = ">=3.10" dependencies = [ + "python-dateutil", "typing_extensions", ] classifiers = [ # pragma: alphabetize diff --git a/src/datetime_tools/_converter.py b/src/datetime_tools/_converter.py index 0795cf6..42e9db1 100644 --- a/src/datetime_tools/_converter.py +++ b/src/datetime_tools/_converter.py @@ -2,6 +2,7 @@ import zoneinfo from typing import Literal +from dateutil import relativedelta from typing_extensions import assert_never @@ -152,3 +153,149 @@ def quantize( return upper_candidate else: # pragma: no cover assert_never(rounding) + + # Relative dates and times + + def day_before( + self, when: datetime_.datetime | datetime_.date + ) -> datetime_.date: + """Find the date of the day before this moment in this timezone. + + Raises: + NaiveDatetime: The datetime is naive, so we do not know which + timezone to localize from. Use `make_aware` to make a naive + datetime timezone-aware. + """ + if isinstance(when, datetime_.datetime): + date = self.date(when) + else: + date = when + + return date - datetime_.timedelta(days=1) + + def day_after( + self, when: datetime_.datetime | datetime_.date + ) -> datetime_.date: + """Find the date of the day after this moment in this timezone. + + Raises: + NaiveDatetime: The datetime is naive, so we do not know which + timezone to localize from. Use `make_aware` to make a naive + datetime timezone-aware. + """ + if isinstance(when, datetime_.datetime): + date = self.date(when) + else: + date = when + + return date + datetime_.timedelta(days=1) + + def midnight( + self, when: datetime_.datetime | datetime_.date + ) -> datetime_.datetime: + """Find the moment of midnight on this day in this timezone. + + Raises: + NaiveDatetime: The datetime is naive, so we do not know which + timezone to localize from. Use `make_aware` to make a naive + datetime timezone-aware. + """ + if isinstance(when, datetime_.datetime): + date = self.date(when) + else: + date = when + + return self.combine(date, datetime_.time(00, 00)) + + def midday( + self, when: datetime_.datetime | datetime_.date + ) -> datetime_.datetime: + """Find the moment of midday on this day in this timezone. + + Raises: + NaiveDatetime: The datetime is naive, so we do not know which + timezone to localize from. Use `make_aware` to make a naive + datetime timezone-aware. + """ + if isinstance(when, datetime_.datetime): + date = self.date(when) + else: + date = when + + return self.combine(date, datetime_.time(12, 00)) + + def next_midnight( + self, when: datetime_.datetime | datetime_.date + ) -> datetime_.datetime: + """Find the moment of midnight at the end of this day in this timezone. + + Raises: + NaiveDatetime: The datetime is naive, so we do not know which + timezone to localize from. Use `make_aware` to make a naive + datetime timezone-aware. + """ + return self.midnight(self.day_after(when)) + + def start_of_month( + self, datetime: datetime_.datetime + ) -> datetime_.datetime: + """Find the moment of the start of this month in this timezone. + + This will be midnight on the first day of the month. + + Raises: + NaiveDatetime: The datetime is naive, so we do not know which + timezone to localize from. Use `make_aware` to make a naive + datetime timezone-aware. + """ + return self.midnight(self.first_day_of_month(datetime)) + + def end_of_month(self, datetime: datetime_.datetime) -> datetime_.datetime: + """Find the moment of the end of this month in this timezone. + + This will be midnight on the first day of the following month. + + Raises: + NaiveDatetime: The datetime is naive, so we do not know which + timezone to localize from. Use `make_aware` to make a naive + datetime timezone-aware. + """ + return self.start_of_month(datetime) + relativedelta.relativedelta( + months=1 + ) + + def first_day_of_month( + self, datetime: datetime_.datetime + ) -> datetime_.date: + """Find the date of the first day of this month in this timezone. + + Raises: + NaiveDatetime: The datetime is naive, so we do not know which + timezone to localize from. Use `make_aware` to make a naive + datetime timezone-aware. + """ + return self.date(datetime).replace(day=1) + + def last_day_of_month( + self, datetime: datetime_.datetime + ) -> datetime_.date: + """Find the date of the last day of this month in this timezone. + + Raises: + NaiveDatetime: The datetime is naive, so we do not know which + timezone to localize from. Use `make_aware` to make a naive + datetime timezone-aware. + """ + return self.first_day_of_month(datetime) + relativedelta.relativedelta( + months=1, days=-1 + ) + + def is_midnight(self, datetime: datetime_.datetime) -> bool: + """Check whether a time is midnight in this timezone. + + Raises: + NaiveDatetime: The datetime is naive, so we do not know which + timezone to localize from. Use `make_aware` to make a naive + datetime timezone-aware. + """ + return self.localize(datetime).time() == datetime_.time(00, 00) diff --git a/tests/test_converter.py b/tests/test_converter.py index d080037..559d10e 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -317,3 +317,381 @@ def test_quantize_requires_aware_datetime() -> None: datetime.timedelta(minutes=1), rounding=paris_time.ROUND_DOWN, ) + + +@pytest.mark.parametrize( + "when", + ( + pytest.param( + # 9th in Paris + datetime.datetime( + 2024, 7, 9, 12, 45, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + id="same timezone", + ), + pytest.param( + # 8th in London; 9th in Paris + datetime.datetime( + 2024, 7, 8, 23, 30, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ), + id="different timezone", + ), + pytest.param( + datetime.date(2024, 7, 9), + id="date", + ), + ), +) +def test_day_before(when: datetime.date | datetime.datetime) -> None: + paris_time = TimezoneConverter("Europe/Paris") + + assert paris_time.day_before(when) == datetime.date(2024, 7, 8) + + +def test_day_before_requires_aware_datetime() -> None: + paris_time = TimezoneConverter("Europe/Paris") + naive_datetime = datetime.datetime(2024, 7, 9, 12, 45, 0, tzinfo=None) + + with pytest.raises(paris_time.NaiveDatetime): + paris_time.day_before(naive_datetime) + + +@pytest.mark.parametrize( + "when", + ( + pytest.param( + # 9th in Paris + datetime.datetime( + 2024, 7, 9, 12, 45, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + id="same timezone", + ), + pytest.param( + # 8th in London; 9th in Paris + datetime.datetime( + 2024, 7, 8, 23, 30, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ), + id="different timezone", + ), + pytest.param( + datetime.date(2024, 7, 9), + id="date", + ), + ), +) +def test_day_after(when: datetime.date | datetime.datetime) -> None: + paris_time = TimezoneConverter("Europe/Paris") + + assert paris_time.day_after(when) == datetime.date(2024, 7, 10) + + +def test_day_after_requires_aware_datetime() -> None: + paris_time = TimezoneConverter("Europe/Paris") + naive_datetime = datetime.datetime(2024, 7, 9, 12, 45, 0, tzinfo=None) + + with pytest.raises(paris_time.NaiveDatetime): + paris_time.day_after(naive_datetime) + + +@pytest.mark.parametrize( + "when", + ( + pytest.param( + # 9th in Paris + datetime.datetime( + 2024, 7, 9, 12, 45, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + id="same timezone", + ), + pytest.param( + # 8th in London; 9th in Paris + datetime.datetime( + 2024, 7, 8, 23, 30, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ), + id="different timezone", + ), + pytest.param( + datetime.date(2024, 7, 9), + id="date", + ), + ), +) +def test_midnight(when: datetime.date | datetime.datetime) -> None: + paris_time = TimezoneConverter("Europe/Paris") + + assert paris_time.midnight(when) == datetime.datetime( + 2024, 7, 9, 00, 00, 00, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ) + + +def test_midnight_requires_aware_datetime() -> None: + paris_time = TimezoneConverter("Europe/Paris") + naive_datetime = datetime.datetime(2024, 7, 9, 12, 45, 0, tzinfo=None) + + with pytest.raises(paris_time.NaiveDatetime): + paris_time.midnight(naive_datetime) + + +@pytest.mark.parametrize( + "when", + ( + pytest.param( + # 9th in Paris + datetime.datetime( + 2024, 7, 9, 12, 45, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + id="same timezone", + ), + pytest.param( + # 8th in London; 9th in Paris + datetime.datetime( + 2024, 7, 8, 23, 30, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ), + id="different timezone", + ), + pytest.param( + datetime.date(2024, 7, 9), + id="date", + ), + ), +) +def test_midday(when: datetime.date | datetime.datetime) -> None: + paris_time = TimezoneConverter("Europe/Paris") + + assert paris_time.midday(when) == datetime.datetime( + 2024, 7, 9, 12, 00, 00, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ) + + +def test_midday_requires_aware_datetime() -> None: + paris_time = TimezoneConverter("Europe/Paris") + naive_datetime = datetime.datetime(2024, 7, 9, 12, 45, 0, tzinfo=None) + + with pytest.raises(paris_time.NaiveDatetime): + paris_time.midday(naive_datetime) + + +@pytest.mark.parametrize( + "when", + ( + pytest.param( + # 9th in Paris + datetime.datetime( + 2024, 7, 9, 12, 45, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + id="same timezone", + ), + pytest.param( + # 8th in London; 9th in Paris + datetime.datetime( + 2024, 7, 8, 23, 30, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ), + id="different timezone", + ), + pytest.param( + datetime.date(2024, 7, 9), + id="date", + ), + ), +) +def test_next_midnight(when: datetime.date | datetime.datetime) -> None: + paris_time = TimezoneConverter("Europe/Paris") + + assert paris_time.next_midnight(when) == datetime.datetime( + 2024, 7, 10, 00, 00, 00, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ) + + +def test_next_midnight_requires_aware_datetime() -> None: + paris_time = TimezoneConverter("Europe/Paris") + naive_datetime = datetime.datetime(2024, 7, 9, 12, 45, 0, tzinfo=None) + + with pytest.raises(paris_time.NaiveDatetime): + paris_time.next_midnight(naive_datetime) + + +@pytest.mark.parametrize( + "when", + ( + pytest.param( + # July in Paris + datetime.datetime( + 2024, 7, 9, 12, 45, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + id="same timezone", + ), + pytest.param( + # June in London; July in Paris + datetime.datetime( + 2024, 6, 30, 23, 30, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ), + id="different timezone", + ), + ), +) +def test_start_of_month(when: datetime.datetime) -> None: + paris_time = TimezoneConverter("Europe/Paris") + + assert paris_time.start_of_month(when) == datetime.datetime( + 2024, 7, 1, 00, 00, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ) + + +def test_start_of_month_requires_aware_datetime() -> None: + paris_time = TimezoneConverter("Europe/Paris") + naive_datetime = datetime.datetime(2024, 7, 9, 12, 45, 0, tzinfo=None) + + with pytest.raises(paris_time.NaiveDatetime): + paris_time.start_of_month(naive_datetime) + + +@pytest.mark.parametrize( + "when", + ( + pytest.param( + # July in Paris + datetime.datetime( + 2024, 7, 9, 12, 45, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + id="same timezone", + ), + pytest.param( + # June in London; July in Paris + datetime.datetime( + 2024, 6, 30, 23, 30, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ), + id="different timezone", + ), + ), +) +def test_end_of_month(when: datetime.datetime) -> None: + paris_time = TimezoneConverter("Europe/Paris") + + assert paris_time.end_of_month(when) == datetime.datetime( + 2024, 8, 1, 00, 00, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ) + + +def test_end_of_month_requires_aware_datetime() -> None: + paris_time = TimezoneConverter("Europe/Paris") + naive_datetime = datetime.datetime(2024, 7, 9, 12, 45, 0, tzinfo=None) + + with pytest.raises(paris_time.NaiveDatetime): + paris_time.end_of_month(naive_datetime) + + +@pytest.mark.parametrize( + "when", + ( + pytest.param( + # July in Paris + datetime.datetime( + 2024, 7, 9, 12, 45, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + id="same timezone", + ), + pytest.param( + # June in London; July in Paris + datetime.datetime( + 2024, 6, 30, 23, 30, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ), + id="different timezone", + ), + ), +) +def test_first_day_of_month(when: datetime.datetime) -> None: + paris_time = TimezoneConverter("Europe/Paris") + + assert paris_time.first_day_of_month(when) == datetime.date(2024, 7, 1) + + +def test_first_day_of_month_requires_aware_datetime() -> None: + paris_time = TimezoneConverter("Europe/Paris") + naive_datetime = datetime.datetime(2024, 7, 9, 12, 45, 0, tzinfo=None) + + with pytest.raises(paris_time.NaiveDatetime): + paris_time.first_day_of_month(naive_datetime) + + +@pytest.mark.parametrize( + "when", + ( + pytest.param( + # July in Paris + datetime.datetime( + 2024, 7, 9, 12, 45, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + id="same timezone", + ), + pytest.param( + # June in London; July in Paris + datetime.datetime( + 2024, 6, 30, 23, 30, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ), + id="different timezone", + ), + ), +) +def test_last_day_of_month(when: datetime.datetime) -> None: + paris_time = TimezoneConverter("Europe/Paris") + + assert paris_time.last_day_of_month(when) == datetime.date(2024, 7, 31) + + +def test_last_day_of_month_requires_aware_datetime() -> None: + paris_time = TimezoneConverter("Europe/Paris") + naive_datetime = datetime.datetime(2024, 7, 9, 12, 45, 0, tzinfo=None) + + with pytest.raises(paris_time.NaiveDatetime): + paris_time.last_day_of_month(naive_datetime) + + +@pytest.mark.parametrize( + "when, is_midnight", + ( + pytest.param( + # midnight in Paris + datetime.datetime( + 2024, 7, 9, 00, 00, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + True, + id="midnight same timezone", + ), + pytest.param( + # 11pm in London; midnight in Paris + datetime.datetime( + 2024, 6, 30, 23, 00, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ), + True, + id="midnight different timezone", + ), + pytest.param( + # 1am in Paris + datetime.datetime( + 2024, 7, 9, 1, 00, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + False, + id="midnight same timezone", + ), + pytest.param( + # midnight in London; 1am in Paris + datetime.datetime( + 2024, 6, 30, 00, 00, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ), + False, + id="midnight different timezone", + ), + ), +) +def test_is_midnight(when: datetime.datetime, is_midnight: bool) -> None: + paris_time = TimezoneConverter("Europe/Paris") + + assert paris_time.is_midnight(when) is is_midnight + + +def test_is_midnight_requires_aware_datetime() -> None: + paris_time = TimezoneConverter("Europe/Paris") + naive_datetime = datetime.datetime(2024, 7, 9, 12, 45, 0, tzinfo=None) + + with pytest.raises(paris_time.NaiveDatetime): + paris_time.is_midnight(naive_datetime) From ad04516c8e61d757222a43140272231974781511 Mon Sep 17 00:00:00 2001 From: Samuel Searles-Bryant Date: Wed, 10 Jul 2024 09:31:26 +0100 Subject: [PATCH 7/9] Add an object to access the system clock This allows callers to make timezone-aware calls to the system clock to get the current date/time in a specified timezone. --- pyproject.toml | 1 + requirements.txt | 8 ++++++++ src/datetime_tools/__init__.py | 6 +++++- src/datetime_tools/_clock.py | 17 +++++++++++++++++ tests/test_clock.py | 28 ++++++++++++++++++++++++++++ 5 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 src/datetime_tools/_clock.py create mode 100644 tests/test_clock.py diff --git a/pyproject.toml b/pyproject.toml index 845d683..196b8cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dev = [ # pragma: alphabetize "coverage", "pre-commit", "pytest", + "time-machine", "tox", "tox-uv", "uv", diff --git a/requirements.txt b/requirements.txt index 0aa498b..2d35fbc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -46,8 +46,16 @@ pyproject-api==1.7.1 # via tox pytest==8.2.2 # via datetime-tools (pyproject.toml) +python-dateutil==2.9.0.post0 + # via + # datetime-tools (pyproject.toml) + # time-machine pyyaml==6.0.1 # via pre-commit +six==1.16.0 + # via python-dateutil +time-machine==2.14.2 + # via datetime-tools (pyproject.toml) tox==4.16.0 # via # datetime-tools (pyproject.toml) diff --git a/src/datetime_tools/__init__.py b/src/datetime_tools/__init__.py index 4c1dce9..7d8fa54 100644 --- a/src/datetime_tools/__init__.py +++ b/src/datetime_tools/__init__.py @@ -2,6 +2,10 @@ Tools for working with timezone-aware datetimes. """ +from ._clock import Clock from ._converter import TimezoneConverter -__all__ = ("TimezoneConverter",) +__all__ = ( + "Clock", + "TimezoneConverter", +) diff --git a/src/datetime_tools/_clock.py b/src/datetime_tools/_clock.py new file mode 100644 index 0000000..404aabd --- /dev/null +++ b/src/datetime_tools/_clock.py @@ -0,0 +1,17 @@ +import datetime +import zoneinfo + + +class Clock: + """Get the current date/time in a specific timezone.""" + + def __init__(self, timezone: str) -> None: + self.tzinfo = zoneinfo.ZoneInfo(timezone) + + # Current time/date + + def now(self) -> datetime.datetime: + return datetime.datetime.now(tz=self.tzinfo) + + def today(self) -> datetime.date: + return self.now().date() diff --git a/tests/test_clock.py b/tests/test_clock.py new file mode 100644 index 0000000..76283f6 --- /dev/null +++ b/tests/test_clock.py @@ -0,0 +1,28 @@ +import datetime +import zoneinfo + +import time_machine + +from datetime_tools import Clock + +# See Note [Use Europe/Paris for tests] + + +def test_now() -> None: + clock = Clock("Europe/Paris") + + with time_machine.travel( # assumes UTC + datetime.datetime(2024, 7, 9, 12, 45, 00), tick=False + ): + assert clock.now() == datetime.datetime( + 2024, 7, 9, 14, 45, 0, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ) + + +def test_today() -> None: + clock = Clock("Europe/Paris") + + with time_machine.travel( # assumes UTC + datetime.datetime(2024, 7, 9, 22, 45, 00), tick=False + ): + assert clock.today() == datetime.date(2024, 7, 10) From 6924938dd3dc807edb8f214f00aad2b93f930c70 Mon Sep 17 00:00:00 2001 From: Samuel Searles-Bryant Date: Wed, 10 Jul 2024 10:01:18 +0100 Subject: [PATCH 8/9] Add methods for getting dates/times relative to the current moment --- src/datetime_tools/_clock.py | 52 ++++++++ tests/test_clock.py | 231 +++++++++++++++++++++++++++++++++++ 2 files changed, 283 insertions(+) diff --git a/src/datetime_tools/_clock.py b/src/datetime_tools/_clock.py index 404aabd..75016e5 100644 --- a/src/datetime_tools/_clock.py +++ b/src/datetime_tools/_clock.py @@ -1,6 +1,8 @@ import datetime import zoneinfo +from dateutil import relativedelta + class Clock: """Get the current date/time in a specific timezone.""" @@ -15,3 +17,53 @@ def now(self) -> datetime.datetime: def today(self) -> datetime.date: return self.now().date() + + # Relative times/dates + + def yesterday(self) -> datetime.date: + return self.days_in_the_past(1) + + def tomorrow(self) -> datetime.date: + return self.days_in_the_future(1) + + def days_in_the_past(self, days: int) -> datetime.date: + return self.today() - datetime.timedelta(days=days) + + def days_in_the_future(self, days: int) -> datetime.date: + return self.today() + datetime.timedelta(days=days) + + def months_in_the_past(self, months: int) -> datetime.date: + """Get the date some number of months ago. + + If the target month does not have enough days, the closest day will be + returned (e.g. 3 months before July 31st is April 30th, not April + 31st). + """ + return self.today() - relativedelta.relativedelta(months=months) + + def months_in_the_future(self, months: int) -> datetime.date: + """Get the date some number of months in the future. + + If the target month does not have enough days, the closest day will be + returned (e.g. 4 months after July 31st is November 30th, not November + 31st). + """ + return self.today() + relativedelta.relativedelta(months=months) + + def is_in_the_past( + self, candidate: datetime.datetime | datetime.date + ) -> bool: + """Check whether a date/time is before the current date/time.""" + if isinstance(candidate, datetime.datetime): + return candidate < self.now() + else: + return candidate < self.today() + + def is_in_the_future( + self, candidate: datetime.datetime | datetime.date + ) -> bool: + """Check whether a date/time is after the current date/time.""" + if isinstance(candidate, datetime.datetime): + return self.now() < candidate + else: + return self.today() < candidate diff --git a/tests/test_clock.py b/tests/test_clock.py index 76283f6..1aa6299 100644 --- a/tests/test_clock.py +++ b/tests/test_clock.py @@ -1,6 +1,7 @@ import datetime import zoneinfo +import pytest import time_machine from datetime_tools import Clock @@ -26,3 +27,233 @@ def test_today() -> None: datetime.datetime(2024, 7, 9, 22, 45, 00), tick=False ): assert clock.today() == datetime.date(2024, 7, 10) + + +def test_yesterday() -> None: + clock = Clock("Europe/Paris") + + with time_machine.travel( # assumes UTC + datetime.datetime(2024, 7, 9, 22, 45, 00), tick=False + ): + assert clock.yesterday() == datetime.date(2024, 7, 9) + + +def test_tomorrow() -> None: + clock = Clock("Europe/Paris") + + with time_machine.travel( # assumes UTC + datetime.datetime(2024, 7, 9, 22, 45, 00), tick=False + ): + assert clock.tomorrow() == datetime.date(2024, 7, 11) + + +def test_days_in_the_past() -> None: + clock = Clock("Europe/Paris") + + with time_machine.travel( # assumes UTC + datetime.datetime(2024, 7, 9, 22, 45, 00), tick=False + ): + assert clock.days_in_the_past(3) == datetime.date(2024, 7, 7) + + +def test_days_in_the_future() -> None: + clock = Clock("Europe/Paris") + + with time_machine.travel( # assumes UTC + datetime.datetime(2024, 7, 9, 22, 45, 00), tick=False + ): + assert clock.days_in_the_future(3) == datetime.date(2024, 7, 13) + + +@pytest.mark.parametrize( + "today, three_months_ago", + ( + pytest.param( + datetime.date(2024, 7, 1), datetime.date(2024, 4, 1), id="1st" + ), + pytest.param( + datetime.date(2024, 7, 28), datetime.date(2024, 4, 28), id="28th" + ), + pytest.param( + datetime.date(2024, 7, 31), datetime.date(2024, 4, 30), id="31st" + ), + ), +) +def test_months_in_the_past( + today: datetime.date, three_months_ago: datetime.date +) -> None: + clock = Clock("Europe/Paris") + + with time_machine.travel(today, tick=False): + assert clock.months_in_the_past(3) == three_months_ago + + +@pytest.mark.parametrize( + "today, four_months_time", + ( + pytest.param( + datetime.date(2024, 7, 1), datetime.date(2024, 11, 1), id="1st" + ), + pytest.param( + datetime.date(2024, 7, 28), datetime.date(2024, 11, 28), id="28th" + ), + pytest.param( + datetime.date(2024, 7, 31), datetime.date(2024, 11, 30), id="31st" + ), + ), +) +def test_months_in_the_future( + today: datetime.date, four_months_time: datetime.date +) -> None: + clock = Clock("Europe/Paris") + + with time_machine.travel(today, tick=False): + assert clock.months_in_the_future(4) == four_months_time + + +@pytest.mark.parametrize( + "when, is_in_the_past", + ( + pytest.param( + datetime.datetime( + 2024, 7, 9, 13, 59, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + True, + id="same timezone, past", + ), + pytest.param( + datetime.datetime( + 2024, 7, 9, 14, 00, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + False, + id="same timezone, now", + ), + pytest.param( + datetime.datetime( + 2024, 7, 9, 14, 1, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + False, + id="same timezone, future", + ), + pytest.param( + datetime.datetime( + 2024, 7, 9, 12, 59, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ), + True, + id="different timezone, past", + ), + pytest.param( + datetime.datetime( + 2024, 7, 9, 13, 00, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ), + False, + id="different timezone, now", + ), + pytest.param( + datetime.datetime( + 2024, 7, 9, 13, 1, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ), + False, + id="different timezone, future", + ), + pytest.param( + datetime.date(2024, 7, 8), + True, + id="day before", + ), + pytest.param( + datetime.date(2024, 7, 9), + False, + id="same day", + ), + pytest.param( + datetime.date(2024, 7, 10), + False, + id="day after", + ), + ), +) +def test_is_in_the_past( + when: datetime.datetime | datetime.date, is_in_the_past: bool +) -> None: + clock = Clock("Europe/Paris") + + with time_machine.travel( + datetime.datetime(2024, 7, 9, 12, 00, tzinfo=zoneinfo.ZoneInfo("UTC")), + tick=False, + ): + assert clock.is_in_the_past(when) is is_in_the_past + + +@pytest.mark.parametrize( + "when, is_in_the_future", + ( + pytest.param( + datetime.datetime( + 2024, 7, 9, 13, 59, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + False, + id="same timezone, past", + ), + pytest.param( + datetime.datetime( + 2024, 7, 9, 14, 00, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + False, + id="same timezone, now", + ), + pytest.param( + datetime.datetime( + 2024, 7, 9, 14, 1, tzinfo=zoneinfo.ZoneInfo("Europe/Paris") + ), + True, + id="same timezone, future", + ), + pytest.param( + datetime.datetime( + 2024, 7, 9, 12, 59, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ), + False, + id="different timezone, past", + ), + pytest.param( + datetime.datetime( + 2024, 7, 9, 13, 00, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ), + False, + id="different timezone, now", + ), + pytest.param( + datetime.datetime( + 2024, 7, 9, 13, 1, tzinfo=zoneinfo.ZoneInfo("Europe/London") + ), + True, + id="different timezone, future", + ), + pytest.param( + datetime.date(2024, 7, 8), + False, + id="day before", + ), + pytest.param( + datetime.date(2024, 7, 9), + False, + id="same day", + ), + pytest.param( + datetime.date(2024, 7, 10), + True, + id="day after", + ), + ), +) +def test_is_in_the_future( + when: datetime.datetime | datetime.date, is_in_the_future: bool +) -> None: + clock = Clock("Europe/Paris") + + with time_machine.travel( + datetime.datetime(2024, 7, 9, 12, 00, tzinfo=zoneinfo.ZoneInfo("UTC")), + tick=False, + ): + assert clock.is_in_the_future(when) is is_in_the_future From 8a1274511d10c37b36914b0724ca8133355f2614 Mon Sep 17 00:00:00 2001 From: Samuel Searles-Bryant Date: Wed, 10 Jul 2024 13:14:01 +0100 Subject: [PATCH 9/9] Implement date helper functions These functions operate on dates, so are unconcerned with timezones. --- src/datetime_tools/__init__.py | 14 ++ src/datetime_tools/_dates.py | 185 +++++++++++++++++++++++ tests/test_dates.py | 258 +++++++++++++++++++++++++++++++++ 3 files changed, 457 insertions(+) create mode 100644 src/datetime_tools/_dates.py create mode 100644 tests/test_dates.py diff --git a/src/datetime_tools/__init__.py b/src/datetime_tools/__init__.py index 7d8fa54..9052d2c 100644 --- a/src/datetime_tools/__init__.py +++ b/src/datetime_tools/__init__.py @@ -4,8 +4,22 @@ from ._clock import Clock from ._converter import TimezoneConverter +from ._dates import ( + DateNotFound, + closest_upcoming_match, + get_contiguous_periods, + is_last_day_of_month, + iter_dates, + latest_date_for_day, +) __all__ = ( "Clock", + "DateNotFound", "TimezoneConverter", + "closest_upcoming_match", + "get_contiguous_periods", + "is_last_day_of_month", + "iter_dates", + "latest_date_for_day", ) diff --git a/src/datetime_tools/_dates.py b/src/datetime_tools/_dates.py new file mode 100644 index 0000000..f15f898 --- /dev/null +++ b/src/datetime_tools/_dates.py @@ -0,0 +1,185 @@ +import datetime +from collections.abc import Collection, Iterator + +from dateutil import relativedelta + +# Note [datetimes are dates] +# ~~~~~~~~~~~~~~~~~~~~~~~~~~ +# Python `datetime` objects are also `date` objects, so type checking will not +# prevent datetimes form being passed to functions that expect dates. To guard +# against unexpected behaviour, the functions in this module explcitly check +# for datetimes and raise a `TypeError` if they are passed in. + + +def is_last_day_of_month(date: datetime.date) -> bool: + """Check whether a date is the last day of the month.""" + if isinstance(date, datetime.datetime): + # See Note [datetimes are dates] + raise TypeError( + "is_last_day_of_month() argument must be a date, " + f"not {type(date)!r}" + ) + + return date.month != (date + datetime.timedelta(days=1)).month + + +class DateNotFound(Exception): + pass + + +def latest_date_for_day( + period: tuple[datetime.date, datetime.date], day_of_month: int +) -> datetime.date: + """Find the latest date in a period with the given calendar day. + + The period must have the dates in order: the start date must be before the + end date. + + Raises: + ValueError: The period is not valid. + DateNotFound: The given date could not be found in the period. + """ + period_start, period_end = period + if isinstance(period_start, datetime.datetime) or isinstance( + period_end, datetime.datetime + ): + # See Note [datetimes are dates] + raise TypeError( + "period must be a pair of dates, " + f"not {(type(period_start), type(period_end))!r}" + ) + if period_end < period_start: + # the period ends before it starts + raise ValueError + + # we can abort early if there will never be a date with this day + if not (1 <= day_of_month <= 31): + raise DateNotFound + + # starting at the end of the period, walk backwards until we reach a date + # with the desired calendar day + candidate = period_end + while candidate >= period_start: + if candidate.day == day_of_month: + return candidate + + candidate -= datetime.timedelta(days=1) + else: + # we tried every date in the period and didn't find one with the + # desired calendar day. + raise DateNotFound + + +def closest_upcoming_match( + preferred_day_of_month: int, after_date: datetime.date +) -> datetime.date: + """Get the next date with the preferred calendar day, within a month. + + Returns: + A date no more than 1 month after `after_date`. + + If there is no date with the preferred calendar day within a month of + the start date (e.g. if the month is too short), the closest date will + be returned. For example, attempting to find the next closest match to + the 31st after April 5th will return April 30th, since that is the + closest to that date within the next month. + + Raises: + ValueError: The preferred calendar day is impossible + """ + if isinstance(after_date, datetime.datetime): + # See Note [datetimes are dates] + raise TypeError(f"after_date must be a date, not {type(after_date)!r}") + + if not (1 <= preferred_day_of_month <= 31): + raise ValueError + + # walk through the month following `after_date` until a matching date + candidate = after_date + datetime.timedelta(days=1) + last_day_in_range = after_date + relativedelta.relativedelta(months=1) + while candidate <= last_day_in_range: + if candidate.day == preferred_day_of_month: + # matching date + return candidate + + if preferred_day_of_month > candidate.day and is_last_day_of_month( + candidate + ): + # end of month before reaching the preferred day; this means the + # month is too short, so we should return this date. + return candidate + + candidate += datetime.timedelta(days=1) + else: # pragma: no cover + # This means we have found neither the end of the month, nor a matching + # date. This should be impossible. + raise RuntimeError + + +def iter_dates( + start: datetime.date, stop: datetime.date +) -> Iterator[datetime.date]: + """Iterate through consecutive dates in a period. + + The period must have the dates in order: the start date must be before the + stop date. + + Yields: + Each date in the period, including the start date and excluding the + stop date. + + Raises: + ValueError: The period is not valid. + """ + if isinstance(start, datetime.datetime) or isinstance( + stop, datetime.datetime + ): + # See Note [datetimes are dates] + raise TypeError( + "period must be a pair of dates, not {(type(start), type(stop))!r}" + ) + + if stop <= start: + # the period ends before it starts + raise ValueError + + date = start + while date < stop: + yield date + date += datetime.timedelta(days=1) + + +def get_contiguous_periods( + dates: Collection[datetime.date], +) -> tuple[tuple[datetime.date, datetime.date], ...]: + """ + Find contiguous periods from a collection of dates. + + Returns: + Pairs of dates that describe the boundaries (inclusive-inclusive) of + contiguous periods of dates in the input sequence. + + For example: + (2024-01-01, 2024-01-02, 2024-01-04, 2024-01-05, 2024-01-06) + becomes + ((2024-01-01, 2024-01-02), (2024-01-04, 2024-01-06)) + """ + if any(isinstance(date, datetime.datetime) for date in dates): + # See Note [datetimes are dates] + raise TypeError("consolidate_into_intervals() arguments must be dates") + + if not dates: + return () + + # step through dates in order and group into contiguous sequences + sorted_dates = sorted(dates) + sequences = [[sorted_dates[0]]] + for date in sorted_dates[1:]: + if sequences[-1][-1] == date - datetime.timedelta(days=1): + # date is contiguous with last sequence: add it to that sequence + sequences[-1].append(date) + else: + # date is disjoint from last sequence: start a new sequence + sequences.append([date]) + + return tuple((sequence[0], sequence[-1]) for sequence in sequences) diff --git a/tests/test_dates.py b/tests/test_dates.py new file mode 100644 index 0000000..3312405 --- /dev/null +++ b/tests/test_dates.py @@ -0,0 +1,258 @@ +import datetime + +import pytest + +import datetime_tools + + +@pytest.mark.parametrize( + "date, is_last_day_of_month", + ( + (datetime.date(2024, 1, 31), True), + (datetime.date(2023, 2, 28), True), + (datetime.date(2024, 2, 29), True), # leap year + (datetime.date(2024, 12, 31), True), # end of year + (datetime.date(2024, 4, 30), True), + (datetime.date(2024, 1, 30), False), + (datetime.date(2024, 2, 28), False), # leap year + ), +) +def test_is_last_day_of_month( + date: datetime.date, is_last_day_of_month: bool +) -> None: + assert datetime_tools.is_last_day_of_month(date) is is_last_day_of_month + + +def test_is_last_day_of_month_requires_date() -> None: + # See Note [datetimes are dates] + with pytest.raises(TypeError): + datetime_tools.is_last_day_of_month(datetime.datetime(2024, 1, 1)) + + +@pytest.mark.parametrize( + "period, day_of_month, latest_date", + ( + pytest.param( + (datetime.date(2024, 1, 1), datetime.date(2024, 12, 31)), + 9, + datetime.date(2024, 12, 9), + id="in range", + ), + pytest.param( + (datetime.date(2024, 1, 1), datetime.date(2024, 12, 8)), + 9, + datetime.date(2024, 11, 9), + id="in previous month", + ), + pytest.param( + (datetime.date(2024, 1, 1), datetime.date(2024, 5, 1)), + 31, + datetime.date(2024, 3, 31), + id="short month", + ), + pytest.param( + (datetime.date(2024, 1, 1), datetime.date(2024, 1, 31)), + 1, + datetime.date(2024, 1, 1), + id="period start", + ), + pytest.param( + (datetime.date(2024, 1, 1), datetime.date(2024, 1, 31)), + 31, + datetime.date(2024, 1, 31), + id="period end", + ), + ), +) +def test_latest_date_for_day( + period: tuple[datetime.date, datetime.date], + day_of_month: int, + latest_date: datetime.date, +) -> None: + assert ( + datetime_tools.latest_date_for_day(period, day_of_month) == latest_date + ) + + +def test_latest_date_for_day_not_found() -> None: + with pytest.raises(datetime_tools.DateNotFound): + datetime_tools.latest_date_for_day( + (datetime.date(2024, 1, 1), datetime.date(2024, 1, 30)), 31 + ) + + +@pytest.mark.parametrize("day_of_month", (-1, 0, 32)) +def test_latest_date_for_day_invalid_day(day_of_month: int) -> None: + with pytest.raises(datetime_tools.DateNotFound): + datetime_tools.latest_date_for_day( + (datetime.date(2024, 1, 1), datetime.date(2024, 1, 31)), + day_of_month, + ) + + +def test_latest_date_for_day_invalid_range() -> None: + """Check that the period must start before it ends.""" + with pytest.raises(ValueError): + datetime_tools.latest_date_for_day( + (datetime.date(2024, 1, 2), datetime.date(2024, 1, 1)), 1 + ) + + +def test_latest_date_for_day_requires_dates() -> None: + # See Note [datetimes are dates] + with pytest.raises(TypeError): + datetime_tools.latest_date_for_day( + (datetime.datetime(2024, 1, 2), datetime.datetime(2024, 1, 1)), 1 + ) + + +@pytest.mark.parametrize( + "after_date, preferred_day_of_month, closest_match", + ( + pytest.param( + datetime.date(2024, 1, 1), + 1, + datetime.date(2024, 2, 1), + id="one month after", + ), + pytest.param( + datetime.date(2024, 1, 20), + 10, + datetime.date(2024, 2, 10), + id="during next month", + ), + pytest.param( + datetime.date(2024, 4, 5), + 31, + datetime.date(2024, 4, 30), + id="end of current month", + ), + pytest.param( + datetime.date(2023, 1, 30), + 30, + datetime.date(2023, 2, 28), + id="end of next month", + ), + pytest.param( + datetime.date(2024, 1, 30), + 30, + datetime.date(2024, 2, 29), + id="end of next month (leap year)", + ), + ), +) +def test_closest_upcoming_match( + after_date: datetime.date, + preferred_day_of_month: int, + closest_match: datetime.date, +) -> None: + assert ( + datetime_tools.closest_upcoming_match( + preferred_day_of_month, after_date=after_date + ) + == closest_match + ) + + +@pytest.mark.parametrize("preferred_day_of_month", (-1, 0, 32)) +def test_closest_upcoming_match_invalid_day( + preferred_day_of_month: int, +) -> None: + with pytest.raises(ValueError): + datetime_tools.closest_upcoming_match( + preferred_day_of_month, after_date=datetime.date(2024, 1, 1) + ) + + +def test_closest_upcoming_match_requires_date() -> None: + # See Note [datetimes are dates] + with pytest.raises(TypeError): + datetime_tools.closest_upcoming_match( + 1, after_date=datetime.datetime(2024, 1, 1) + ) + + +def test_iter_dates() -> None: + iterator = datetime_tools.iter_dates( + datetime.date(2024, 1, 1), datetime.date(2024, 1, 4) + ) + + assert next(iterator) == datetime.date(2024, 1, 1) + assert next(iterator) == datetime.date(2024, 1, 2) + assert next(iterator) == datetime.date(2024, 1, 3) + with pytest.raises(StopIteration): + next(iterator) + + +@pytest.mark.parametrize( + "start, stop", + ( + pytest.param( + datetime.date(2024, 1, 2), + datetime.date(2024, 1, 1), + id="stop before start", + ), + pytest.param( + datetime.date(2024, 1, 1), + datetime.date(2024, 1, 1), + id="stop at start", + ), + ), +) +def test_iter_dates_invalid_range( + start: datetime.date, stop: datetime.date +) -> None: + iterator = datetime_tools.iter_dates(start, stop) + with pytest.raises(ValueError): + next(iterator) + + +def test_iter_dates_requires_dates() -> None: + # See Note [datetimes are dates] + iterator = datetime_tools.iter_dates( + datetime.datetime(2024, 1, 1), datetime.datetime(2024, 1, 4) + ) + with pytest.raises(TypeError): + next(iterator) + + +def test_get_contiguous_periods() -> None: + assert datetime_tools.get_contiguous_periods( + ( + # Jan 1 - Jan 3 + datetime.date(2024, 1, 1), + datetime.date(2024, 1, 2), + datetime.date(2024, 1, 3), + # Jan 10 on its own + datetime.date(2024, 1, 10), + # Jan 7 - Jan 8 + datetime.date(2024, 1, 7), + datetime.date(2024, 1, 8), + # Jan 4 - Jan 5 + datetime.date(2024, 1, 4), + datetime.date(2024, 1, 5), + # Jan 14 - Jan 12 (reverse order) + datetime.date(2024, 1, 14), + datetime.date(2024, 1, 13), + datetime.date(2024, 1, 12), + ) + ) == ( + # Jan 1 - Jan 5 + (datetime.date(2024, 1, 1), datetime.date(2024, 1, 5)), + # Jan 7 - Jan 8 + (datetime.date(2024, 1, 7), datetime.date(2024, 1, 8)), + # Jan 10 on its own + (datetime.date(2024, 1, 10), datetime.date(2024, 1, 10)), + # Jan 12 - Jan 14 + (datetime.date(2024, 1, 12), datetime.date(2024, 1, 14)), + ) + + +def test_get_contiguous_periods_empty() -> None: + assert datetime_tools.get_contiguous_periods(()) == () + + +def test_get_contiguous_periods_requires_dates() -> None: + # See Note [datetimes are dates] + with pytest.raises(TypeError): + datetime_tools.get_contiguous_periods((datetime.datetime(2024, 1, 1),))