Skip to content

Commit

Permalink
Merge pull request #178 from octoenergy/ranges/dt-to-date-range
Browse files Browse the repository at this point in the history
Add "localize" and "date_range_for_midnight_range" fns
  • Loading branch information
benthorner authored Dec 11, 2024
2 parents 2276ed7 + 88c461f commit 7f34d7c
Show file tree
Hide file tree
Showing 4 changed files with 234 additions and 69 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
248 changes: 180 additions & 68 deletions tests/test_ranges.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import copy
import datetime
import re
import zoneinfo
from typing import Any

import pytest
Expand Down Expand Up @@ -318,6 +319,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(
Expand Down Expand Up @@ -888,52 +917,116 @@ 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 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:
Expand Down Expand Up @@ -967,35 +1060,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",
Expand Down Expand Up @@ -1123,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.
Expand Down
49 changes: 49 additions & 0 deletions xocto/ranges.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]):
"""
Expand Down Expand Up @@ -1067,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),
)

0 comments on commit 7f34d7c

Please sign in to comment.