Skip to content

Commit b960b3d

Browse files
Recurring time window filter (#51)
* added recurring time window filter * Fixing Daily * fixing review items * Review Comments * Review comments * Fixing days of the week * Code review items * Updating to deal with optional start/end * Updated settings usage * adding tests and fixes * Recurrence validator tests * Adding more tests * Fixing bug where first day of the week had to be part of recurrence * formatting * Update test_recurrence_validator.py * review comments * fix tests * Update test_recurrence_validator.py * Create __init__.py * review comments * Added more tests and checks * fix formatting * Update .gitignore * Update .gitignore * Update _models.py * Update _models.py * num_of_occurrences > 0 * review comments * added tests fixed max int * Update tests/time_window_filter/test_recurrence_evaluator.py Co-authored-by: Zhiyuan Liang <[email protected]> * Added comments to test * Update test_recurrence_evaluator.py --------- Co-authored-by: Zhiyuan Liang <[email protected]>
1 parent 678a154 commit b960b3d

File tree

9 files changed

+1216
-6
lines changed

9 files changed

+1216
-6
lines changed

featuremanagement/_defaultfilters.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from email.utils import parsedate_to_datetime
1010
from typing import cast, List, Mapping, Optional, Dict, Any
1111
from ._featurefilters import FeatureFilter
12+
from ._time_window_filter import Recurrence, is_match, TimeWindowFilterSettings
1213

1314
FEATURE_FLAG_NAME_KEY = "feature_name"
1415
ROLLOUT_PERCENTAGE_KEY = "RolloutPercentage"
@@ -18,6 +19,15 @@
1819
# Time Window Constants
1920
START_KEY = "Start"
2021
END_KEY = "End"
22+
TIME_WINDOW_FILTER_SETTING_RECURRENCE = "Recurrence"
23+
24+
# Time Window Exceptions
25+
TIME_WINDOW_FILTER_INVALID = (
26+
"{}: The {} feature filter is not valid for feature {}. It must specify either {}, {}, or both."
27+
)
28+
TIME_WINDOW_FILTER_INVALID_RECURRENCE = (
29+
"{}: The {} feature filter is not valid for feature {}. It must specify both {} and {} when Recurrence is not None."
30+
)
2131

2232
# Targeting kwargs
2333
TARGETED_USER_KEY = "user"
@@ -31,6 +41,8 @@
3141
FEATURE_FILTER_NAME_KEY = "Name"
3242
IGNORE_CASE_KEY = "ignore_case"
3343

44+
logger = logging.getLogger(__name__)
45+
3446

3547
class TargetingException(Exception):
3648
"""
@@ -52,19 +64,35 @@ def evaluate(self, context: Mapping[Any, Any], **kwargs: Any) -> bool:
5264
:return: True if the current time is within the time window.
5365
:rtype: bool
5466
"""
55-
start = context.get(PARAMETERS_KEY, {}).get(START_KEY)
56-
end = context.get(PARAMETERS_KEY, {}).get(END_KEY)
67+
start = context.get(PARAMETERS_KEY, {}).get(START_KEY, None)
68+
end = context.get(PARAMETERS_KEY, {}).get(END_KEY, None)
69+
recurrence_data = context.get(PARAMETERS_KEY, {}).get(TIME_WINDOW_FILTER_SETTING_RECURRENCE, None)
70+
recurrence = None
5771

5872
current_time = datetime.now(timezone.utc)
5973

6074
if not start and not end:
61-
logging.warning("%s: At least one of Start or End is required.", TimeWindowFilter.__name__)
75+
logger.warning(
76+
TIME_WINDOW_FILTER_INVALID,
77+
TimeWindowFilter.__name__,
78+
context.get(FEATURE_FLAG_NAME_KEY),
79+
START_KEY,
80+
END_KEY,
81+
)
6282
return False
6383

64-
start_time = parsedate_to_datetime(start) if start else None
65-
end_time = parsedate_to_datetime(end) if end else None
84+
start_time: Optional[datetime] = parsedate_to_datetime(start) if start else None
85+
end_time: Optional[datetime] = parsedate_to_datetime(end) if end else None
86+
87+
if (start_time is None or start_time <= current_time) and (end_time is None or current_time < end_time):
88+
return True
89+
90+
if recurrence_data:
91+
recurrence = Recurrence(recurrence_data)
92+
settings = TimeWindowFilterSettings(start_time, end_time, recurrence)
93+
return is_match(settings, current_time)
6694

67-
return (start_time is None or start_time <= current_time) and (end_time is None or current_time < end_time)
95+
return False
6896

6997

7098
@FeatureFilter.alias("Microsoft.Targeting")
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# ------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for
4+
# license information.
5+
# -------------------------------------------------------------------------
6+
from ._recurrence_evaluator import is_match
7+
from ._models import Recurrence, TimeWindowFilterSettings
8+
9+
__all__ = ["is_match", "Recurrence", "TimeWindowFilterSettings"]
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# ------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for
4+
# license information.
5+
# -------------------------------------------------------------------------
6+
from enum import Enum
7+
from typing import Dict, Any, Optional, List
8+
from datetime import datetime
9+
from dataclasses import dataclass
10+
from email.utils import parsedate_to_datetime
11+
12+
13+
class RecurrencePatternType(str, Enum):
14+
"""
15+
The recurrence pattern type.
16+
"""
17+
18+
DAILY = "Daily"
19+
WEEKLY = "Weekly"
20+
21+
@staticmethod
22+
def from_str(value: str) -> "RecurrencePatternType":
23+
"""
24+
Get the RecurrencePatternType from the string value.
25+
26+
:param value: The string value.
27+
:type value: str
28+
:return: The RecurrencePatternType.
29+
:rtype: RecurrencePatternType
30+
"""
31+
if value == "Daily":
32+
return RecurrencePatternType.DAILY
33+
if value == "Weekly":
34+
return RecurrencePatternType.WEEKLY
35+
raise ValueError(f"Invalid value: {value}")
36+
37+
38+
class RecurrenceRangeType(str, Enum):
39+
"""
40+
The recurrence range type.
41+
"""
42+
43+
NO_END = "NoEnd"
44+
END_DATE = "EndDate"
45+
NUMBERED = "Numbered"
46+
47+
@staticmethod
48+
def from_str(value: str) -> "RecurrenceRangeType":
49+
"""
50+
Get the RecurrenceRangeType from the string value.
51+
52+
:param value: The string value.
53+
:type value: str
54+
:return: The RecurrenceRangeType.
55+
:rtype: RecurrenceRangeType
56+
"""
57+
if value == "NoEnd":
58+
return RecurrenceRangeType.NO_END
59+
if value == "EndDate":
60+
return RecurrenceRangeType.END_DATE
61+
if value == "Numbered":
62+
return RecurrenceRangeType.NUMBERED
63+
raise ValueError(f"Invalid value: {value}")
64+
65+
66+
class RecurrencePattern: # pylint: disable=too-few-public-methods
67+
"""
68+
The recurrence pattern settings.
69+
"""
70+
71+
days: List[str] = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
72+
73+
def __init__(self, pattern_data: Dict[str, Any]):
74+
self.type = RecurrencePatternType.from_str(pattern_data.get("Type", "Daily"))
75+
self.interval = pattern_data.get("Interval", 1)
76+
if self.interval <= 0:
77+
raise ValueError("The interval must be greater than 0.")
78+
# Days of the week are represented as a list of strings of their names.
79+
days_of_week_str = pattern_data.get("DaysOfWeek", [])
80+
81+
# Days of the week are represented as a list of integers from 0 to 6.
82+
self.days_of_week: List[int] = []
83+
for day in days_of_week_str:
84+
if day not in self.days:
85+
raise ValueError(f"Invalid value for DaysOfWeek: {day}")
86+
if self.days.index(day) in self.days_of_week:
87+
raise ValueError(f"Duplicate day of the week found: {day}")
88+
self.days_of_week.append(self.days.index(day))
89+
if pattern_data.get("FirstDayOfWeek") and pattern_data.get("FirstDayOfWeek") not in self.days:
90+
raise ValueError(f"Invalid value for FirstDayOfWeek: {pattern_data.get('FirstDayOfWeek')}")
91+
self.first_day_of_week = self.days.index(pattern_data.get("FirstDayOfWeek", "Sunday"))
92+
93+
94+
class RecurrenceRange: # pylint: disable=too-few-public-methods
95+
"""
96+
The recurrence range settings.
97+
"""
98+
99+
type: RecurrenceRangeType
100+
end_date: Optional[datetime] = None
101+
102+
def __init__(self, range_data: Dict[str, Any]):
103+
self.type = RecurrenceRangeType.from_str(range_data.get("Type", "NoEnd"))
104+
if range_data.get("EndDate") and isinstance(range_data.get("EndDate"), str):
105+
end_date_str = range_data.get("EndDate", "")
106+
try:
107+
self.end_date = parsedate_to_datetime(end_date_str) if end_date_str else None
108+
except ValueError as e:
109+
raise ValueError(f"Invalid value for EndDate: {end_date_str}") from e
110+
except TypeError as e:
111+
# Python 3.9 and earlier throw TypeError if the string is not in RFC 2822 format.
112+
raise ValueError(f"Invalid value for EndDate: {end_date_str}") from e
113+
self.num_of_occurrences = range_data.get("NumberOfOccurrences", 2**63 - 1)
114+
if self.num_of_occurrences <= 0:
115+
raise ValueError("The number of occurrences must be greater than 0.")
116+
117+
118+
class Recurrence: # pylint: disable=too-few-public-methods
119+
"""
120+
The recurrence settings.
121+
"""
122+
123+
pattern: RecurrencePattern
124+
range: RecurrenceRange
125+
126+
def __init__(self, recurrence_data: Dict[str, Any]):
127+
self.pattern = RecurrencePattern(recurrence_data.get("Pattern", {}))
128+
self.range = RecurrenceRange(recurrence_data.get("Range", {}))
129+
130+
131+
@dataclass
132+
class TimeWindowFilterSettings:
133+
"""
134+
The settings for the time window filter.
135+
"""
136+
137+
start: Optional[datetime]
138+
end: Optional[datetime]
139+
recurrence: Optional[Recurrence]
140+
141+
142+
@dataclass
143+
class OccurrenceInfo:
144+
"""
145+
The information of the previous occurrence.
146+
"""
147+
148+
previous_occurrence: datetime
149+
num_of_occurrences: int
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# ------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for
4+
# license information.
5+
# -------------------------------------------------------------------------
6+
from datetime import datetime, timedelta
7+
from typing import Optional
8+
from ._models import RecurrencePatternType, RecurrenceRangeType, TimeWindowFilterSettings, OccurrenceInfo, Recurrence
9+
from ._recurrence_validator import validate_settings, _get_passed_week_days, _sort_days_of_week
10+
11+
DAYS_PER_WEEK = 7
12+
REQUIRED_PARAMETER = "Required parameter: %s"
13+
14+
15+
def is_match(settings: TimeWindowFilterSettings, now: datetime) -> bool:
16+
"""
17+
Check if the current time is within the time window filter settings.
18+
19+
:param TimeWindowFilterSettings settings: The settings for the time window filter.
20+
:param datetime now: The current time.
21+
:return: True if the current time is within the time window filter settings, otherwise False.
22+
:rtype: bool
23+
"""
24+
recurrence = settings.recurrence
25+
if recurrence is None:
26+
raise ValueError(REQUIRED_PARAMETER % "Recurrence")
27+
28+
start = settings.start
29+
end = settings.end
30+
if start is None or end is None:
31+
raise ValueError(REQUIRED_PARAMETER % "Start or End")
32+
33+
validate_settings(recurrence, start, end)
34+
35+
previous_occurrence = _get_previous_occurrence(recurrence, start, now)
36+
if previous_occurrence is None:
37+
return False
38+
39+
occurrence_end_date = previous_occurrence + (end - start)
40+
return now < occurrence_end_date
41+
42+
43+
def _get_previous_occurrence(recurrence: Recurrence, start: datetime, now: datetime) -> Optional[datetime]:
44+
if now < start:
45+
return None
46+
47+
pattern_type = recurrence.pattern.type
48+
if pattern_type == RecurrencePatternType.DAILY:
49+
occurrence_info = _get_daily_previous_occurrence(recurrence, start, now)
50+
elif pattern_type == RecurrencePatternType.WEEKLY:
51+
occurrence_info = _get_weekly_previous_occurrence(recurrence, start, now)
52+
else:
53+
raise ValueError(f"Unsupported recurrence pattern type: {pattern_type}")
54+
55+
recurrence_range = recurrence.range
56+
range_type = recurrence_range.type
57+
previous_occurrence = occurrence_info.previous_occurrence
58+
end_date = recurrence_range.end_date
59+
if (
60+
range_type == RecurrenceRangeType.END_DATE
61+
and previous_occurrence is not None
62+
and end_date is not None
63+
and previous_occurrence > end_date
64+
):
65+
return None
66+
if (
67+
range_type == RecurrenceRangeType.NUMBERED
68+
and recurrence_range.num_of_occurrences is not None
69+
and occurrence_info.num_of_occurrences > recurrence_range.num_of_occurrences
70+
):
71+
return None
72+
73+
return occurrence_info.previous_occurrence
74+
75+
76+
def _get_daily_previous_occurrence(recurrence: Recurrence, start: datetime, now: datetime) -> OccurrenceInfo:
77+
interval = recurrence.pattern.interval
78+
num_of_occurrences = (now - start).days // interval
79+
previous_occurrence = start + timedelta(days=num_of_occurrences * interval)
80+
return OccurrenceInfo(previous_occurrence, num_of_occurrences + 1)
81+
82+
83+
def _get_weekly_previous_occurrence(recurrence: Recurrence, start: datetime, now: datetime) -> OccurrenceInfo:
84+
pattern = recurrence.pattern
85+
interval = pattern.interval
86+
first_day_of_first_week = start - timedelta(days=_get_passed_week_days(start.weekday(), pattern.first_day_of_week))
87+
88+
number_of_interval = (now - first_day_of_first_week).days // (interval * DAYS_PER_WEEK)
89+
first_day_of_most_recent_occurring_week = first_day_of_first_week + timedelta(
90+
days=number_of_interval * (interval * DAYS_PER_WEEK)
91+
)
92+
sorted_days_of_week = _sort_days_of_week(pattern.days_of_week, pattern.first_day_of_week)
93+
max_day_offset = _get_passed_week_days(sorted_days_of_week[-1], pattern.first_day_of_week)
94+
min_day_offset = _get_passed_week_days(sorted_days_of_week[0], pattern.first_day_of_week)
95+
num_of_occurrences = number_of_interval * len(sorted_days_of_week) - sorted_days_of_week.index(start.weekday())
96+
97+
if now > first_day_of_most_recent_occurring_week + timedelta(days=DAYS_PER_WEEK):
98+
num_of_occurrences += len(sorted_days_of_week)
99+
most_recent_occurrence = first_day_of_most_recent_occurring_week + timedelta(days=max_day_offset)
100+
return OccurrenceInfo(most_recent_occurrence, num_of_occurrences)
101+
102+
day_with_min_offset = first_day_of_most_recent_occurring_week + timedelta(days=min_day_offset)
103+
if start > day_with_min_offset:
104+
num_of_occurrences = 0
105+
day_with_min_offset = start
106+
if now < day_with_min_offset:
107+
most_recent_occurrence = (
108+
first_day_of_most_recent_occurring_week
109+
- timedelta(days=interval * DAYS_PER_WEEK)
110+
+ timedelta(days=max_day_offset)
111+
)
112+
else:
113+
most_recent_occurrence = day_with_min_offset
114+
num_of_occurrences += 1
115+
116+
for day in sorted_days_of_week[sorted_days_of_week.index(day_with_min_offset.weekday()) + 1 :]:
117+
day_with_min_offset = first_day_of_most_recent_occurring_week + timedelta(
118+
days=_get_passed_week_days(day, pattern.first_day_of_week)
119+
)
120+
if now < day_with_min_offset:
121+
break
122+
most_recent_occurrence = day_with_min_offset
123+
num_of_occurrences += 1
124+
125+
return OccurrenceInfo(most_recent_occurrence, num_of_occurrences)

0 commit comments

Comments
 (0)