diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b15ede..75bee5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Add `FiniteDatetimeRange.as_date_range` [#181](https://github.com/octoenergy/xocto/pull/181) +- [Breaking] Remove `ranges.date_range_for_midnight_range` (replaced by FiniteDatetimeRange.as_date_range)[#181] (https://github.com/octoenergy/xocto/pull/181) + ## v6.2.0 - 2024-12-11 - Add `ranges.date_range_for_midnight_range` [#178] (https://github.com/octoenergy/xocto/pull/178) diff --git a/tests/test_ranges.py b/tests/test_ranges.py index ea3273f..035e10c 100644 --- a/tests/test_ranges.py +++ b/tests/test_ranges.py @@ -1028,6 +1028,53 @@ def test_errors_if_naive(self): assert "naive" in str(exc_info.value) + class TestAsDateRange: + def test_returns_date_range(self): + dt_range = ranges.FiniteDatetimeRange( + datetime.datetime(2020, 1, 1), + datetime.datetime(2020, 1, 10), + ) + + assert dt_range.as_date_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: + dt_range.as_date_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: + dt_range.as_date_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: + dt_range.as_date_range() + + assert "End of range is not midnight-aligned" in str(exc_info.value) + class TestAsFiniteDatetimePeriods: def test_converts(self): @@ -1187,54 +1234,6 @@ 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 e7743aa..a193140 100644 --- a/xocto/ranges.py +++ b/xocto/ranges.py @@ -904,6 +904,36 @@ def localize(self, tz: datetime.tzinfo) -> FiniteDatetimeRange: self.end.astimezone(tz), ) + def as_date_range( + self: FiniteDatetimeRange, + ) -> FiniteDateRange: + """ + Returns the date range covered by this range (if midnight-aligned). + + 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 self.start.tzinfo != self.end.tzinfo: + raise ValueError("Start and end in different timezones") + + # Check datetimes are both midnight-aligned. + if self.start.time() != datetime.time(0, 0): + raise ValueError("Start of range is not midnight-aligned") + + if self.end.time() != datetime.time(0, 0): + raise ValueError("End of range is not midnight-aligned") + + return FiniteDateRange( + self.start.date(), + self.end.date() - datetime.timedelta(days=1), + ) + class FiniteDateRange(FiniteRange[datetime.date]): """ @@ -1085,34 +1115,3 @@ 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), - )