From 787ba6559b5b9bf4833f2c613cd44ed2d4a7fe73 Mon Sep 17 00:00:00 2001 From: Ben Thorner Date: Wed, 4 Dec 2024 15:53:54 +0000 Subject: [PATCH 1/4] Consolidate ranges test classes by class under test This file was previously an inconsistent mix of classes targetting whole classes (of methods) vs a single method of a class. Nesting test classes for specific methods under test is simplest - avoids the need to name test classes to represent class + method together. --- tests/test_ranges.py | 136 +++++++++++++++++++++---------------------- 1 file changed, 68 insertions(+), 68 deletions(-) diff --git a/tests/test_ranges.py b/tests/test_ranges.py index 6108b0d..b026b56 100644 --- a/tests/test_ranges.py +++ b/tests/test_ranges.py @@ -318,6 +318,34 @@ def test_range_difference_and_intersection_form_partition( # Ignore types here as structuring this to appease mypy would make it v ugly. assert (a_difference | intersection | b_difference) == (a | b) # type: ignore[operator] + class TestCopy: + def test_range_copy(self): + r1 = ranges.Range(1, 2) + r2 = copy.copy(r1) + assert r1 == r2 + + def test_range_deepcopy(self): + r1 = ranges.Range(1, 2) + r2 = copy.deepcopy(r1) + assert r1 == r2 + + @pytest.mark.parametrize( + "obj", + [ + ranges.Range(1, 2), + ranges.FiniteDateRange( + datetime.date(2000, 1, 1), datetime.date(2000, 1, 2) + ), + ranges.FiniteDatetimeRange( + datetime.datetime(2000, 1, 1), datetime.datetime(2000, 1, 2) + ), + ranges.HalfFiniteRange(1, 2), + ], + ids=("range", "date_range", "datetime_range", "half_finite_range"), + ) + def test_copies(self, obj): + assert obj == copy.copy(obj) == copy.deepcopy(obj) + class TestRangeSet: @pytest.mark.parametrize( @@ -888,52 +916,53 @@ def test_finite_range(self): assert 3 in subject -class TestFiniteDatetimeRangeUnion: - def test_union_of_touching_ranges(self): - range = ranges.FiniteDatetimeRange( - start=datetime.datetime(2000, 1, 1), - end=datetime.datetime(2000, 1, 2), - ) - other = ranges.FiniteDatetimeRange( - start=datetime.datetime(2000, 1, 2), - end=datetime.datetime(2000, 1, 3), - ) +class TestFiniteDatetimeRange: + class TestUnion: + def test_union_of_touching_ranges(self): + range = ranges.FiniteDatetimeRange( + start=datetime.datetime(2000, 1, 1), + end=datetime.datetime(2000, 1, 2), + ) + other = ranges.FiniteDatetimeRange( + start=datetime.datetime(2000, 1, 2), + end=datetime.datetime(2000, 1, 3), + ) - union = range | other + union = range | other - assert union == ranges.FiniteDatetimeRange( - start=datetime.datetime(2000, 1, 1), - end=datetime.datetime(2000, 1, 3), - ) + assert union == ranges.FiniteDatetimeRange( + start=datetime.datetime(2000, 1, 1), + end=datetime.datetime(2000, 1, 3), + ) - def test_union_of_disjoint_ranges(self): - range = ranges.FiniteDateRange( - start=datetime.datetime(2000, 1, 1), - end=datetime.datetime(2000, 1, 2), - ) - other = ranges.FiniteDatetimeRange( - start=datetime.datetime(2020, 1, 1), - end=datetime.datetime(2020, 1, 2), - ) + def test_union_of_disjoint_ranges(self): + range = ranges.FiniteDateRange( + start=datetime.datetime(2000, 1, 1), + end=datetime.datetime(2000, 1, 2), + ) + other = ranges.FiniteDatetimeRange( + start=datetime.datetime(2020, 1, 1), + end=datetime.datetime(2020, 1, 2), + ) - assert range | other is None + assert range | other is None - def test_union_of_overlapping_ranges(self): - range = ranges.FiniteDatetimeRange( - start=datetime.datetime(2000, 1, 1), - end=datetime.datetime(2000, 1, 3), - ) - other = ranges.FiniteDatetimeRange( - start=datetime.datetime(2000, 1, 2), - end=datetime.datetime(2000, 1, 4), - ) + def test_union_of_overlapping_ranges(self): + range = ranges.FiniteDatetimeRange( + start=datetime.datetime(2000, 1, 1), + end=datetime.datetime(2000, 1, 3), + ) + other = ranges.FiniteDatetimeRange( + start=datetime.datetime(2000, 1, 2), + end=datetime.datetime(2000, 1, 4), + ) - union = range | other + union = range | other - assert union == ranges.FiniteDatetimeRange( - start=datetime.datetime(2000, 1, 1), - end=datetime.datetime(2000, 1, 4), - ) + assert union == ranges.FiniteDatetimeRange( + start=datetime.datetime(2000, 1, 1), + end=datetime.datetime(2000, 1, 4), + ) class TestAsFiniteDatetimePeriods: @@ -967,35 +996,6 @@ def test_errors_if_infinite(self): assert "Period is not finite at start or end or both" in str(exc_info.value) -class TestRangeCopy: - def test_range_copy(self): - r1 = ranges.Range(1, 2) - r2 = copy.copy(r1) - assert r1 == r2 - - def test_range_deepcopy(self): - r1 = ranges.Range(1, 2) - r2 = copy.deepcopy(r1) - assert r1 == r2 - - @pytest.mark.parametrize( - "obj", - [ - ranges.Range(1, 2), - ranges.FiniteDateRange( - datetime.date(2000, 1, 1), datetime.date(2000, 1, 2) - ), - ranges.FiniteDatetimeRange( - datetime.datetime(2000, 1, 1), datetime.datetime(2000, 1, 2) - ), - ranges.HalfFiniteRange(1, 2), - ], - ids=("range", "date_range", "datetime_range", "half_finite_range"), - ) - def test_copies(self, obj): - assert obj == copy.copy(obj) == copy.deepcopy(obj) - - class TestIterateOverMonths: @pytest.mark.parametrize( "row", From 6d0afaca3bc979f62723ebb3cd277db26184f345 Mon Sep 17 00:00:00 2001 From: Ben Thorner Date: Wed, 4 Dec 2024 16:03:57 +0000 Subject: [PATCH 2/4] Add FiniteDatetimeRange.localize timezone method This works towards DRYing-up converting ranges to date ranges in the next commits - localizing helps ensure a range is aligned to midnight, even if it was stored/retrieved as e.g. UTC. --- tests/test_ranges.py | 64 ++++++++++++++++++++++++++++++++++++++++++++ xocto/ranges.py | 18 +++++++++++++ 2 files changed, 82 insertions(+) diff --git a/tests/test_ranges.py b/tests/test_ranges.py index b026b56..086a206 100644 --- a/tests/test_ranges.py +++ b/tests/test_ranges.py @@ -3,6 +3,7 @@ import copy import datetime import re +import zoneinfo from typing import Any import pytest @@ -964,6 +965,69 @@ def test_union_of_overlapping_ranges(self): end=datetime.datetime(2000, 1, 4), ) + class TestLocalize: + def test_converts_timezone(self): + # Create a datetime range in Sydney, which is + # 7 hours ahead of Dubai (target timezone). + source_tz = zoneinfo.ZoneInfo("Australia/Sydney") # GMT+11 + target_tz = zoneinfo.ZoneInfo("Asia/Dubai") # GMT+4 + + dt_range = ranges.FiniteDatetimeRange( + datetime.datetime(2020, 1, 1, hour=7, tzinfo=source_tz), + datetime.datetime(2020, 1, 10, hour=7, tzinfo=source_tz), + ) + + assert dt_range.localize(target_tz) == ranges.FiniteDatetimeRange( + datetime.datetime(2020, 1, 1, tzinfo=target_tz), + datetime.datetime(2020, 1, 10, tzinfo=target_tz), + ) + + def test_errors_converting_over_dst_gain_hour(self): + utc_tz = zoneinfo.ZoneInfo("UTC") + london_tz = zoneinfo.ZoneInfo("Europe/London") + + # Create a range in London over the hour that is "gained" + # when Daylight Savings Time (DST) starts - at 1AM. + # + # Note: this is allowed by datetime but not a realistic + # example - "1AM" here doesn't actually exist in GBR. + dt_range = ranges.FiniteDatetimeRange( + datetime.datetime(2020, 3, 29, hour=1, tzinfo=london_tz), + datetime.datetime(2020, 3, 29, hour=2, tzinfo=london_tz), + ) + + # Converting to UTC should error due to the period being + # empty: removing the "fake hour" means 2AM => 1AM. + with pytest.raises(ValueError): + assert dt_range.localize(utc_tz) + + def test_errors_converting_over_dst_loss_hour(self): + utc_tz = zoneinfo.ZoneInfo("UTC") + london_tz = zoneinfo.ZoneInfo("Europe/London") + + # Create a range in UTC over the hour before Daylight Savings + # Time (DST) ends - at 2AM. + dt_range = ranges.FiniteDatetimeRange( + datetime.datetime(2020, 10, 25, hour=0, tzinfo=utc_tz), + datetime.datetime(2020, 10, 25, hour=1, tzinfo=utc_tz), + ) + + # Converting to London timezone should error due to the period + # being empty: both times map to 1AM. + with pytest.raises(ValueError): + assert dt_range.localize(london_tz) + + def test_errors_if_naive(self): + tz = zoneinfo.ZoneInfo("Europe/London") + + with pytest.raises(ValueError) as exc_info: + ranges.FiniteDatetimeRange( + datetime.datetime(2020, 1, 1), + datetime.datetime(2020, 1, 10), + ).localize(tz) + + assert "naive" in str(exc_info.value) + class TestAsFiniteDatetimePeriods: def test_converts(self): diff --git a/xocto/ranges.py b/xocto/ranges.py index 119589e..ea91a27 100644 --- a/xocto/ranges.py +++ b/xocto/ranges.py @@ -886,6 +886,24 @@ def seconds(self) -> int: """ return int((self.end - self.start).total_seconds()) + def localize(self, tz: datetime.tzinfo) -> FiniteDatetimeRange: + """ + Returns the range with boundaries adjusted to the specified timezone. + + See datetime.astimezone for more details. + + Raises: + ValueError: + If one or both boundaries are naive (no timezone). + """ + if not self.start.tzinfo or not self.end.tzinfo: + raise ValueError("Cannot localize range with naive boundaries") + + return FiniteDatetimeRange( + self.start.astimezone(tz), + self.end.astimezone(tz), + ) + class FiniteDateRange(FiniteRange[datetime.date]): """ From c840f923a0c805ef3911afb154960b60da2a4e18 Mon Sep 17 00:00:00 2001 From: Ben Thorner Date: Fri, 22 Nov 2024 11:19:48 +0000 Subject: [PATCH 3/4] Add date_range_for_midnight_range utility function This will be used to DRY-up converting datetime ranges. Before: ranges.FiniteDateRange( midnight_aligned_range.start.date(), midnight_aligned_range.end.date() - timedelta(days=1), ) After: ranges.date_range_for_midnight_range(midnight_aligned_range) Pull Request in Kraken Core to demo usage [^1]. [^1]: https://github.com/octoenergy/kraken-core/pull/166268 --- tests/test_ranges.py | 48 ++++++++++++++++++++++++++++++++++++++++++++ xocto/ranges.py | 31 ++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/tests/test_ranges.py b/tests/test_ranges.py index 086a206..ea3273f 100644 --- a/tests/test_ranges.py +++ b/tests/test_ranges.py @@ -1187,6 +1187,54 @@ def test_yields_correct_ranges(self, row): assert result == row["expected"] +class TestDateRangeForMidnightRange: + def test_returns_date_range(self): + dt_range = ranges.FiniteDatetimeRange( + datetime.datetime(2020, 1, 1), + datetime.datetime(2020, 1, 10), + ) + + assert ranges.date_range_for_midnight_range(dt_range) == ranges.FiniteDateRange( + datetime.date(2020, 1, 1), + datetime.date(2020, 1, 9), + ) + + def test_errors_if_different_timezones(self): + dt_range = ranges.FiniteDatetimeRange( + datetime.datetime(2020, 1, 1, tzinfo=zoneinfo.ZoneInfo("Asia/Dubai")), + datetime.datetime( + 2020, 1, 10, tzinfo=zoneinfo.ZoneInfo("Australia/Sydney") + ), + ) + + with pytest.raises(ValueError) as exc_info: + ranges.date_range_for_midnight_range(dt_range) + + assert "Start and end in different timezones" in str(exc_info.value) + + def test_errors_if_start_not_midnight(self): + dt_range = ranges.FiniteDatetimeRange( + datetime.datetime(2020, 1, 1, hour=1), + datetime.datetime(2020, 1, 10), + ) + + with pytest.raises(ValueError) as exc_info: + ranges.date_range_for_midnight_range(dt_range) + + assert "Start of range is not midnight-aligned" in str(exc_info.value) + + def test_errors_if_end_not_midnight(self): + dt_range = ranges.FiniteDatetimeRange( + datetime.datetime(2020, 1, 1), + datetime.datetime(2020, 1, 10, hour=1), + ) + + with pytest.raises(ValueError) as exc_info: + ranges.date_range_for_midnight_range(dt_range) + + assert "End of range is not midnight-aligned" in str(exc_info.value) + + def _rangeset_from_string(rangeset_str: str) -> ranges.RangeSet[int]: """ Convenience method to make test declarations clearer. diff --git a/xocto/ranges.py b/xocto/ranges.py index ea91a27..e7743aa 100644 --- a/xocto/ranges.py +++ b/xocto/ranges.py @@ -1085,3 +1085,34 @@ def iterate_over_months( yield FiniteDatetimeRange(start_at, this_end) start_at = next_start + + +def date_range_for_midnight_range( + range: FiniteDatetimeRange, +) -> FiniteDateRange: + """ + Returns the date range of a midnight-aligned datetime range. + + This can be useful where a range is available at datetime granularity, + but is used in functions that operate at date granularity. + + Raises: + ValueError: + If the range boundaries are in different timezeones. + If the range boundaries are not midnight-aligned. + """ + # First check range timezone is uniform. + if range.start.tzinfo != range.end.tzinfo: + raise ValueError("Start and end in different timezones") + + # Check datetimes are both midnight-aligned. + if range.start.time() != datetime.time(0, 0): + raise ValueError("Start of range is not midnight-aligned") + + if range.end.time() != datetime.time(0, 0): + raise ValueError("End of range is not midnight-aligned") + + return FiniteDateRange( + range.start.date(), + range.end.date() - datetime.timedelta(days=1), + ) From 88c461fb0899122d7b0cb909efff65b5bce9f547 Mon Sep 17 00:00:00 2001 From: Ben Thorner Date: Wed, 11 Dec 2024 10:02:38 +0000 Subject: [PATCH 4/4] Bump version to 6.2.0 and update CHANGELOG --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4dc337..2b15ede 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +## v6.2.0 - 2024-12-11 + +- Add `ranges.date_range_for_midnight_range` [#178] (https://github.com/octoenergy/xocto/pull/178) +- Add `FiniteDatetimeRange.localize` [#178] (https://github.com/octoenergy/xocto/pull/178) ## v6.1.0 - 2024-08-30 diff --git a/pyproject.toml b/pyproject.toml index 2ce72fd..b74b6f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "xocto" -version = "6.1.0" +version = "6.2.0" requires-python = ">=3.9" description = "Kraken Technologies Python service utilities" readme = "README.md"