-
Notifications
You must be signed in to change notification settings - Fork 97
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add DateutilTimePeriodAdjuster
#1233
Changes from all commits
cfb3f52
f8d699e
020b169
7d872da
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,114 @@ | ||
from __future__ import annotations | ||
|
||
import datetime | ||
from typing import Optional | ||
|
||
import dateutil.relativedelta | ||
from dateutil.relativedelta import relativedelta | ||
from dbt_semantic_interfaces.enum_extension import assert_values_exhausted | ||
from dbt_semantic_interfaces.type_enums import TimeGranularity | ||
from typing_extensions import override | ||
|
||
from metricflow_semantics.filters.time_constraint import TimeRangeConstraint | ||
from metricflow_semantics.time.time_period import TimePeriodAdjuster | ||
|
||
|
||
class DateutilTimePeriodAdjuster(TimePeriodAdjuster): | ||
"""Implementation of time period adjustments using `dateutil`. | ||
|
||
* `relativedelta` will not change weekday if already at the given weekday, even with a Nth parameter. | ||
* `relativedelta` will automatically handle day values that exceed the number of days in months with < 31 days. | ||
""" | ||
|
||
def _relative_delta_for_window(self, time_granularity: TimeGranularity, count: int) -> relativedelta: | ||
"""Relative-delta to cover time windows specified at different grains.""" | ||
if time_granularity is TimeGranularity.DAY: | ||
return relativedelta(days=count) | ||
elif time_granularity is TimeGranularity.WEEK: | ||
return relativedelta(weeks=count) | ||
elif time_granularity is TimeGranularity.MONTH: | ||
return relativedelta(months=count) | ||
elif time_granularity is TimeGranularity.QUARTER: | ||
return relativedelta(months=count * 3) | ||
elif time_granularity is TimeGranularity.YEAR: | ||
return relativedelta(years=count) | ||
else: | ||
assert_values_exhausted(time_granularity) | ||
|
||
@override | ||
def expand_time_constraint_to_fill_granularity( | ||
self, time_constraint: TimeRangeConstraint, granularity: TimeGranularity | ||
) -> TimeRangeConstraint: | ||
adjusted_start = self.adjust_to_start_of_period(granularity, time_constraint.start_time) | ||
adjusted_end = self.adjust_to_end_of_period(granularity, time_constraint.end_time) | ||
|
||
if adjusted_start < TimeRangeConstraint.ALL_TIME_BEGIN(): | ||
adjusted_start = TimeRangeConstraint.ALL_TIME_BEGIN() | ||
if adjusted_end > TimeRangeConstraint.ALL_TIME_END(): | ||
adjusted_end = TimeRangeConstraint.ALL_TIME_END() | ||
|
||
return TimeRangeConstraint(start_time=adjusted_start, end_time=adjusted_end) | ||
|
||
@override | ||
def adjust_to_start_of_period( | ||
self, time_granularity: TimeGranularity, date_to_adjust: datetime.datetime | ||
) -> datetime.datetime: | ||
if time_granularity is TimeGranularity.DAY: | ||
return date_to_adjust | ||
elif time_granularity is TimeGranularity.WEEK: | ||
return date_to_adjust + relativedelta(weekday=dateutil.relativedelta.MO(-1)) | ||
elif time_granularity is TimeGranularity.MONTH: | ||
return date_to_adjust + relativedelta(day=1) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This API.... TIL Oh well, it works, just read very carefully is all..... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Improved documentation and more examples of that API would have helped - had to do play around with the methods in the REPL. |
||
elif time_granularity is TimeGranularity.QUARTER: | ||
if date_to_adjust.month <= 3: | ||
return date_to_adjust + relativedelta(month=1, day=1) | ||
elif date_to_adjust.month <= 6: | ||
return date_to_adjust + relativedelta(month=4, day=1) | ||
elif date_to_adjust.month <= 9: | ||
return date_to_adjust + relativedelta(month=7, day=1) | ||
else: | ||
return date_to_adjust + relativedelta(month=10, day=1) | ||
elif time_granularity is TimeGranularity.YEAR: | ||
return date_to_adjust + relativedelta(month=1, day=1) | ||
else: | ||
assert_values_exhausted(time_granularity) | ||
|
||
@override | ||
def adjust_to_end_of_period( | ||
self, time_granularity: TimeGranularity, date_to_adjust: datetime.datetime | ||
) -> datetime.datetime: | ||
if time_granularity is TimeGranularity.DAY: | ||
return date_to_adjust | ||
elif time_granularity is TimeGranularity.WEEK: | ||
return date_to_adjust + relativedelta(weekday=dateutil.relativedelta.SU(1)) | ||
elif time_granularity is TimeGranularity.MONTH: | ||
return date_to_adjust + relativedelta(day=31) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for putting that docstring note on the class, I would've wondered about this. |
||
elif time_granularity is TimeGranularity.QUARTER: | ||
if date_to_adjust.month <= 3: | ||
return date_to_adjust + relativedelta(month=3, day=31) | ||
elif date_to_adjust.month <= 6: | ||
return date_to_adjust + relativedelta(month=6, day=31) | ||
elif date_to_adjust.month <= 9: | ||
return date_to_adjust + relativedelta(month=9, day=31) | ||
else: | ||
return date_to_adjust + relativedelta(month=12, day=31) | ||
elif time_granularity is TimeGranularity.YEAR: | ||
return date_to_adjust + relativedelta(month=12, day=31) | ||
else: | ||
assert_values_exhausted(time_granularity) | ||
|
||
@override | ||
def expand_time_constraint_for_cumulative_metric( | ||
self, time_constraint: TimeRangeConstraint, granularity: Optional[TimeGranularity], count: int | ||
) -> TimeRangeConstraint: | ||
if granularity is not None: | ||
return TimeRangeConstraint( | ||
start_time=time_constraint.start_time - self._relative_delta_for_window(granularity, count), | ||
end_time=time_constraint.end_time, | ||
) | ||
|
||
# if no window is specified we want to accumulate from the beginning of time | ||
return TimeRangeConstraint( | ||
start_time=TimeRangeConstraint.ALL_TIME_BEGIN(), | ||
end_time=time_constraint.end_time, | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This assumes all input has a minimum granularity of daily and silently returns whatever is passed in. The Pandas implementation raises an exception in this case, which might be preferable for testing scenarios.
I'm fine with either approach since we're going to update this within the next couple of weeks, but @courtneyholcomb is going to be doing the work on opening up granularity so maybe check in with her about what she prefers before merging.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pinged her, but preferring to fix later if there are issues to unblock merging of the stack.