From d9eca62b886a513e4d8f6ad6420bafdd6ac7068a Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Fri, 22 Nov 2024 11:16:33 -0800 Subject: [PATCH 01/32] added recurring time window filter --- featuremanagement/_defaultfilters.py | 50 +++++- .../_time_window_filter/__init__.py | 9 + .../_time_window_filter/_models.py | 82 +++++++++ .../_recurrence_evaluator.py | 117 +++++++++++++ .../_recurrence_validator.py | 161 ++++++++++++++++++ .../_time_window_filter/conversiontest.py | 0 6 files changed, 414 insertions(+), 5 deletions(-) create mode 100644 featuremanagement/_time_window_filter/__init__.py create mode 100644 featuremanagement/_time_window_filter/_models.py create mode 100644 featuremanagement/_time_window_filter/_recurrence_evaluator.py create mode 100644 featuremanagement/_time_window_filter/_recurrence_validator.py create mode 100644 featuremanagement/_time_window_filter/conversiontest.py diff --git a/featuremanagement/_defaultfilters.py b/featuremanagement/_defaultfilters.py index cffc0bd..fc2d476 100644 --- a/featuremanagement/_defaultfilters.py +++ b/featuremanagement/_defaultfilters.py @@ -9,6 +9,7 @@ from email.utils import parsedate_to_datetime from typing import cast, List, Mapping, Optional, Dict, Any from ._featurefilters import FeatureFilter +from ._time_window_filter import Recurrence, is_match, TimeWindowFilterSettings FEATURE_FLAG_NAME_KEY = "feature_name" ROLLOUT_PERCENTAGE_KEY = "RolloutPercentage" @@ -18,6 +19,15 @@ # Time Window Constants START_KEY = "Start" END_KEY = "End" +TIME_WINDOW_FILTER_SETTING_RECURRENCE = "Recurrence" + +# Time Window Exceptions +TIME_WINDOW_FILTER_INVALID = ( + "%s: The %s feature filter is not valid for feature %s. It must specify either %s, $s, or both." +) +TIME_WINDOW_FILTER_INVALID_RECURRENCE = ( + "%s: The %s feature filter is not valid for feature %s. It must specify both %s and $s when Recurrence is not None." +) # Targeting kwargs TARGETED_USER_KEY = "user" @@ -31,6 +41,8 @@ FEATURE_FILTER_NAME_KEY = "Name" IGNORE_CASE_KEY = "ignore_case" +logger = logging.getLogger(__name__) + class TargetingException(Exception): """ @@ -52,17 +64,45 @@ def evaluate(self, context: Mapping[Any, Any], **kwargs: Any) -> bool: :return: True if the current time is within the time window. :rtype: bool """ - start = context.get(PARAMETERS_KEY, {}).get(START_KEY) - end = context.get(PARAMETERS_KEY, {}).get(END_KEY) + start = context.get(PARAMETERS_KEY, {}).get(START_KEY, None) + end = context.get(PARAMETERS_KEY, {}).get(END_KEY, None) + recurrence_data = context.get(PARAMETERS_KEY, {}).get(TIME_WINDOW_FILTER_SETTING_RECURRENCE, None) + recurrence = None current_time = datetime.now(timezone.utc) + if recurrence_data: + recurrence = Recurrence(**cast(Dict[str, Any], recurrence_data)) + if not start and not end: - logging.warning("%s: At least one of Start or End is required.", TimeWindowFilter.__name__) + logger.warning( + TIME_WINDOW_FILTER_INVALID, + TimeWindowFilter.__name__, + context.get(FEATURE_FLAG_NAME_KEY), + START_KEY, + END_KEY, + ) return False - start_time = parsedate_to_datetime(start) if start else None - end_time = parsedate_to_datetime(end) if end else None + start_time: Optional[datetime] = parsedate_to_datetime(start) if start else None + end_time: Optional[datetime] = parsedate_to_datetime(end) if end else None + + if recurrence: + if start_time and end_time: + settings = TimeWindowFilterSettings(start_time, end_time, recurrence) + return is_match(settings, current_time) + logger.warning( + TIME_WINDOW_FILTER_INVALID_RECURRENCE, + TimeWindowFilter.__name__, + context.get(FEATURE_FLAG_NAME_KEY), + START_KEY, + END_KEY, + ) + return False + + if not start and not end: + logging.warning("%s: At least one of Start or End is required.", TimeWindowFilter.__name__) + return False return (start_time is None or start_time <= current_time) and (end_time is None or current_time < end_time) diff --git a/featuremanagement/_time_window_filter/__init__.py b/featuremanagement/_time_window_filter/__init__.py new file mode 100644 index 0000000..34290bd --- /dev/null +++ b/featuremanagement/_time_window_filter/__init__.py @@ -0,0 +1,9 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- +from ._recurrence_evaluator import is_match +from ._models import Recurrence, TimeWindowFilterSettings + +__all__ = ["is_match", "Recurrence", "TimeWindowFilterSettings"] diff --git a/featuremanagement/_time_window_filter/_models.py b/featuremanagement/_time_window_filter/_models.py new file mode 100644 index 0000000..3ea3058 --- /dev/null +++ b/featuremanagement/_time_window_filter/_models.py @@ -0,0 +1,82 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- +from enum import Enum +from datetime import datetime +from typing import List +from dataclasses import dataclass + + +class RecurrencePatternType(str, Enum): + """ + The recurrence pattern type. + """ + + DAILY = "Daily" + WEEKLY = "Weekly" + + +class RecurrenceRangeType(str, Enum): + """ + The recurrence range type. + """ + + NO_END = "NoEnd" + END_DATE = "EndDate" + NUMBERED = "Numbered" + + +@dataclass +class RecurrencePattern: + """ + The recurrence pattern settings. + """ + + days_of_week: List[int] + interval: int = 1 + first_day_of_week: int = 7 + type: RecurrencePatternType = RecurrencePatternType.DAILY + + +@dataclass +class RecurrenceRange: + """ + The recurrence range settings. + """ + + end_date: datetime + num_of_occurrences: int + type: RecurrenceRangeType = RecurrenceRangeType.NO_END + + +@dataclass +class Recurrence: + """ + The recurrence settings. + """ + + pattern: RecurrencePattern + range: RecurrenceRange + + +@dataclass +class TimeWindowFilterSettings: + """ + The settings for the time window filter. + """ + + start: datetime + end: datetime + recurrence: Recurrence + + +@dataclass +class OccurrenceInfo: + """ + The information of the previous occurrence. + """ + + previous_occurrence: datetime + num_of_occurrences: int diff --git a/featuremanagement/_time_window_filter/_recurrence_evaluator.py b/featuremanagement/_time_window_filter/_recurrence_evaluator.py new file mode 100644 index 0000000..71f579d --- /dev/null +++ b/featuremanagement/_time_window_filter/_recurrence_evaluator.py @@ -0,0 +1,117 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- +from datetime import datetime, timedelta +from typing import List, Optional +from ._models import RecurrencePatternType, RecurrenceRangeType, TimeWindowFilterSettings, OccurrenceInfo +from ._recurrence_validator import validate_settings + + +def is_match(settings: TimeWindowFilterSettings, now: datetime) -> bool: + """ + Check if the current time is within the time window filter settings. + + :param TimeWindowFilterSettings settings: The settings for the time window filter. + :param datetime now: The current time. + :return: True if the current time is within the time window filter settings, otherwise False. + :rtype: bool + """ + validate_settings(settings) + + previous_occurrence = _get_previous_occurrence(settings, now) + if previous_occurrence is None: + return False + + occurrence_end_date = previous_occurrence + (settings.end - settings.start) + return now < occurrence_end_date + + +def _get_previous_occurrence(settings: TimeWindowFilterSettings, now: datetime) -> Optional[datetime]: + start = settings.start + if now < start: + return None + + pattern_type = settings.recurrence.pattern.type + if pattern_type == RecurrencePatternType.DAILY: + occurrence_info = _get_daily_previous_occurrence(settings, now) + else: + occurrence_info = _get_weekly_previous_occurrence(settings, now) + + recurrence_range = settings.recurrence.range + range_type = recurrence_range.type + if ( + range_type == RecurrenceRangeType.END_DATE + and occurrence_info.previous_occurrence + and occurrence_info.previous_occurrence > recurrence_range.end_date + ): + return None + if ( + range_type == RecurrenceRangeType.NUMBERED + and occurrence_info.num_of_occurrences > recurrence_range.num_of_occurrences + ): + return None + + return occurrence_info.previous_occurrence + + +def _get_daily_previous_occurrence(settings: TimeWindowFilterSettings, now: datetime) -> OccurrenceInfo: + start = settings.start + interval = settings.recurrence.pattern.interval + num_of_occurrences = (now - start).days // interval + previous_occurrence = start + timedelta(days=num_of_occurrences * interval) + return OccurrenceInfo(previous_occurrence, num_of_occurrences + 1) + + +def _get_weekly_previous_occurrence(settings: TimeWindowFilterSettings, now: datetime) -> OccurrenceInfo: + pattern = settings.recurrence.pattern + interval = pattern.interval + start = settings.start + first_day_of_first_week = start - timedelta(days=_get_passed_week_days(start.weekday(), pattern.first_day_of_week)) + + number_of_interval = (now - first_day_of_first_week).days // (interval * 7) + first_day_of_most_recent_occurring_week = first_day_of_first_week + timedelta( + days=number_of_interval * (interval * 7) + ) + sorted_days_of_week = _sort_days_of_week(pattern.days_of_week, pattern.first_day_of_week) + max_day_offset = _get_passed_week_days(sorted_days_of_week[-1], pattern.first_day_of_week) + min_day_offset = _get_passed_week_days(sorted_days_of_week[0], pattern.first_day_of_week) + num_of_occurrences = number_of_interval * len(sorted_days_of_week) - sorted_days_of_week.index(start.weekday()) + + if now > first_day_of_most_recent_occurring_week + timedelta(days=7): + num_of_occurrences += len(sorted_days_of_week) + most_recent_occurrence = first_day_of_most_recent_occurring_week + timedelta(days=max_day_offset) + return OccurrenceInfo(most_recent_occurrence, num_of_occurrences) + + day_with_min_offset = first_day_of_most_recent_occurring_week + timedelta(days=min_day_offset) + if start > day_with_min_offset: + num_of_occurrences = 0 + day_with_min_offset = start + if now < day_with_min_offset: + most_recent_occurrence = ( + first_day_of_most_recent_occurring_week - timedelta(days=interval * 7) + timedelta(days=max_day_offset) + ) + else: + most_recent_occurrence = day_with_min_offset + num_of_occurrences += 1 + + for day in sorted_days_of_week[sorted_days_of_week.index(day_with_min_offset.weekday()) + 1 :]: + day_with_min_offset = first_day_of_most_recent_occurring_week + timedelta( + days=_get_passed_week_days(day, pattern.first_day_of_week) + ) + if now < day_with_min_offset: + break + most_recent_occurrence = day_with_min_offset + num_of_occurrences += 1 + + return OccurrenceInfo(most_recent_occurrence, num_of_occurrences) + + +def _get_passed_week_days(current_day: int, first_day_of_week: int) -> int: + return (current_day - first_day_of_week + 7) % 7 + + +def _sort_days_of_week(days_of_week: List[int], first_day_of_week: int) -> List[int]: + sorted_days = sorted(days_of_week) + return sorted_days[sorted_days.index(first_day_of_week) :] + sorted_days[: sorted_days.index(first_day_of_week)] diff --git a/featuremanagement/_time_window_filter/_recurrence_validator.py b/featuremanagement/_time_window_filter/_recurrence_validator.py new file mode 100644 index 0000000..509a2ac --- /dev/null +++ b/featuremanagement/_time_window_filter/_recurrence_validator.py @@ -0,0 +1,161 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- +from datetime import datetime, timedelta +from typing import List +from ._models import RecurrencePatternType, RecurrenceRangeType, TimeWindowFilterSettings + + +DAYS_PER_WEEK = 7 +TEN_YEARS = 3650 +RECURRENCE_PATTERN = "Pattern" +RECURRENCE_PATTERN_DAYS_OF_WEEK = "DaysOfWeek" +RECURRENCE_RANGE = "Range" +REQUIRED_PARAMETER = "Required parameter: %s" +OUT_OF_RANGE = "Out of range: %s" +TIME_WINDOW_DURATION_TEN_YEARS = "Time window duration exceeds ten years: %s" +NOT_MATCHED = "Start day does not match any day of the week: %s" +TIME_WINDOW_DURATION_OUT_OF_RANGE = "Time window duration is out of range: %s" + + +def validate_settings(settings: TimeWindowFilterSettings) -> None: + """ + Validate the settings for the time window filter. + + :param TimeWindowFilterSettings settings: The settings for the time window filter. + :raises ValueError: If the settings are invalid. + """ + _validate_recurrence_required_parameter(settings) + _validate_recurrence_pattern(settings) + _validate_recurrence_range(settings) + + +def _validate_recurrence_required_parameter(settings: TimeWindowFilterSettings) -> None: + recurrence = settings.recurrence + param_name = "" + reason = "" + if recurrence.pattern is None: + param_name = f"{RECURRENCE_PATTERN}" + reason = REQUIRED_PARAMETER + if recurrence.range is None: + param_name = f"{RECURRENCE_RANGE}" + reason = REQUIRED_PARAMETER + if not settings.end > settings.start: + param_name = "end" + reason = OUT_OF_RANGE + if settings.end > settings.start + timedelta(days=TEN_YEARS): + param_name = "end" + reason = TIME_WINDOW_DURATION_TEN_YEARS + + if param_name: + raise ValueError(reason % param_name) + + +def _validate_recurrence_pattern(settings: TimeWindowFilterSettings) -> None: + pattern_type = settings.recurrence.pattern.type + + if pattern_type == RecurrencePatternType.DAILY: + _validate_daily_recurrence_pattern(settings) + else: + _validate_weekly_recurrence_pattern(settings) + + +def _validate_recurrence_range(settings: TimeWindowFilterSettings) -> None: + range_type = settings.recurrence.range.type + if range_type == RecurrenceRangeType.END_DATE: + _validate_end_date(settings) + + +def _validate_daily_recurrence_pattern(settings: TimeWindowFilterSettings) -> None: + # "Start" is always a valid first occurrence for "Daily" pattern. + # Only need to check if time window validated + _validate_time_window_duration(settings) + + +def _validate_weekly_recurrence_pattern(settings: TimeWindowFilterSettings) -> None: + _validate_days_of_week(settings) + + # Check whether "Start" is a valid first occurrence + pattern = settings.recurrence.pattern + if settings.start.weekday() not in pattern.days_of_week: + raise ValueError(NOT_MATCHED % "start") + + # Time window duration must be shorter than how frequently it occurs + _validate_time_window_duration(settings) + + # Check whether the time window duration is shorter than the minimum gap between days of week + if not _is_duration_compliant_with_days_of_week(settings): + raise ValueError(TIME_WINDOW_DURATION_OUT_OF_RANGE % "Recurrence.Pattern.DaysOfWeek") + + +def _validate_time_window_duration(settings: TimeWindowFilterSettings) -> None: + pattern = settings.recurrence.pattern + interval_duration = ( + timedelta(days=pattern.interval) + if pattern.type == RecurrencePatternType.DAILY + else timedelta(days=pattern.interval * DAYS_PER_WEEK) + ) + time_window_duration = settings.end - settings.start + if time_window_duration > interval_duration: + raise ValueError(TIME_WINDOW_DURATION_OUT_OF_RANGE % "Recurrence.Pattern.Interval") + + +def _validate_days_of_week(settings: TimeWindowFilterSettings) -> None: + days_of_week = settings.recurrence.pattern.days_of_week + if not days_of_week: + raise ValueError(REQUIRED_PARAMETER % "Recurrence.Pattern.DaysOfWeek") + + +def _validate_end_date(settings: TimeWindowFilterSettings) -> None: + if settings.recurrence.range.end_date < settings.start: + raise ValueError("The Recurrence.Range.EndDate should be after the Start") + + +def _is_duration_compliant_with_days_of_week(settings: TimeWindowFilterSettings) -> bool: + days_of_week = settings.recurrence.pattern.days_of_week + if len(days_of_week) == 1: + return True + + # Get the date of first day of the week + today = datetime.now() + first_day_of_week = settings.recurrence.pattern.first_day_of_week + offset = _get_passed_week_days(today.weekday(), first_day_of_week) + first_date_of_week = today - timedelta(days=offset) + sorted_days_of_week = _sort_days_of_week(days_of_week, first_day_of_week) + + # Loop the whole week to get the min gap between the two consecutive recurrences + prev_occurrence = None + min_gap = timedelta(days=DAYS_PER_WEEK) + + for day in sorted_days_of_week: + date = first_date_of_week + timedelta(days=_get_passed_week_days(day, first_day_of_week)) + if prev_occurrence is not None: + current_gap = date - prev_occurrence + min_gap = min(min_gap, current_gap) + prev_occurrence = date + + if settings.recurrence.pattern.interval == 1: + # It may cross weeks. Check the adjacent week + date = first_date_of_week + timedelta( + days=DAYS_PER_WEEK + _get_passed_week_days(sorted_days_of_week[0], first_day_of_week) + ) + + if not prev_occurrence: + return False + + current_gap = date - prev_occurrence + min_gap = min(min_gap, current_gap) + + time_window_duration = settings.end - settings.start + return min_gap >= time_window_duration + + +def _get_passed_week_days(today: int, first_day_of_week: int) -> int: + return (today - first_day_of_week + DAYS_PER_WEEK) % DAYS_PER_WEEK + + +def _sort_days_of_week(days_of_week: List[int], first_day_of_week: int) -> List[int]: + sorted_days = sorted(days_of_week, key=lambda day: _get_passed_week_days(day, first_day_of_week)) + return sorted_days diff --git a/featuremanagement/_time_window_filter/conversiontest.py b/featuremanagement/_time_window_filter/conversiontest.py new file mode 100644 index 0000000..e69de29 From 5339273f677fca91603fd400019159d8197fbc3c Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Fri, 22 Nov 2024 15:13:15 -0800 Subject: [PATCH 02/32] Fixing Daily --- featuremanagement/_defaultfilters.py | 3 +- .../_time_window_filter/_models.py | 45 ++++++++++++------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/featuremanagement/_defaultfilters.py b/featuremanagement/_defaultfilters.py index fc2d476..ed7fec9 100644 --- a/featuremanagement/_defaultfilters.py +++ b/featuremanagement/_defaultfilters.py @@ -72,7 +72,7 @@ def evaluate(self, context: Mapping[Any, Any], **kwargs: Any) -> bool: current_time = datetime.now(timezone.utc) if recurrence_data: - recurrence = Recurrence(**cast(Dict[str, Any], recurrence_data)) + recurrence = Recurrence(recurrence_data) if not start and not end: logger.warning( @@ -106,7 +106,6 @@ def evaluate(self, context: Mapping[Any, Any], **kwargs: Any) -> bool: return (start_time is None or start_time <= current_time) and (end_time is None or current_time < end_time) - @FeatureFilter.alias("Microsoft.Targeting") class TargetingFilter(FeatureFilter): """ diff --git a/featuremanagement/_time_window_filter/_models.py b/featuremanagement/_time_window_filter/_models.py index 3ea3058..e31a0fb 100644 --- a/featuremanagement/_time_window_filter/_models.py +++ b/featuremanagement/_time_window_filter/_models.py @@ -5,8 +5,9 @@ # ------------------------------------------------------------------------- from enum import Enum from datetime import datetime -from typing import List +from typing import Self from dataclasses import dataclass +from email.utils import parsedate_to_datetime class RecurrencePatternType(str, Enum): @@ -17,6 +18,13 @@ class RecurrencePatternType(str, Enum): DAILY = "Daily" WEEKLY = "Weekly" + def from_str(value: str) -> Self: + if value == "Daily": + return RecurrencePatternType.DAILY + if value == "Weekly": + return RecurrencePatternType.WEEKLY + raise ValueError(f"Invalid value: {value}") + class RecurrenceRangeType(str, Enum): """ @@ -27,38 +35,45 @@ class RecurrenceRangeType(str, Enum): END_DATE = "EndDate" NUMBERED = "Numbered" + def from_str(value: str) -> Self: + if value == "NoEnd": + return RecurrenceRangeType.NO_END + if value == "EndDate": + return RecurrenceRangeType.END_DATE + if value == "Numbered": + return RecurrenceRangeType.NUMBERED + raise ValueError(f"Invalid value: {value}") -@dataclass class RecurrencePattern: """ The recurrence pattern settings. """ - days_of_week: List[int] - interval: int = 1 - first_day_of_week: int = 7 - type: RecurrencePatternType = RecurrencePatternType.DAILY + def __init__(self, pattern_data: dict[str: any]): + self.type = RecurrencePatternType.from_str(pattern_data.get("Type", "Daily")) + self.interval = pattern_data.get("Interval", 1) + self.days_of_week = pattern_data.get("DaysOfWeek", []) + self.first_day_of_week = pattern_data.get("FirstDayOfWeek", 7) - -@dataclass class RecurrenceRange: """ The recurrence range settings. """ - end_date: datetime - num_of_occurrences: int - type: RecurrenceRangeType = RecurrenceRangeType.NO_END - + def __init__(self, range_data: dict[str: any]): + self.type = RecurrenceRangeType.from_str(range_data.get("Type", "NoEnd")) + if range_data.get("EndDate"): + self.end_date = parsedate_to_datetime(range_data.get("EndDate")) + self.num_of_occurrences = range_data.get("NumberOfOccurrences") -@dataclass class Recurrence: """ The recurrence settings. """ - pattern: RecurrencePattern - range: RecurrenceRange + def __init__(self, recurrence_data: dict[str: any]): + self.pattern = RecurrencePattern(recurrence_data.get("Pattern")) + self.range = RecurrenceRange(recurrence_data.get("Range")) @dataclass From eabad846eaa7e274bba76d346151c796e2c05378 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Wed, 11 Dec 2024 16:15:37 -0800 Subject: [PATCH 03/32] fixing review items --- featuremanagement/_defaultfilters.py | 5 +- .../_time_window_filter/_models.py | 48 ++++++++++++++----- .../_recurrence_evaluator.py | 8 +++- .../_recurrence_validator.py | 3 +- 4 files changed, 46 insertions(+), 18 deletions(-) diff --git a/featuremanagement/_defaultfilters.py b/featuremanagement/_defaultfilters.py index ed7fec9..2c55509 100644 --- a/featuremanagement/_defaultfilters.py +++ b/featuremanagement/_defaultfilters.py @@ -23,10 +23,10 @@ # Time Window Exceptions TIME_WINDOW_FILTER_INVALID = ( - "%s: The %s feature filter is not valid for feature %s. It must specify either %s, $s, or both." + "{}: The {} feature filter is not valid for feature {}. It must specify either {}, {}, or both." ) TIME_WINDOW_FILTER_INVALID_RECURRENCE = ( - "%s: The %s feature filter is not valid for feature %s. It must specify both %s and $s when Recurrence is not None." + "{}: The {} feature filter is not valid for feature {}. It must specify both {} and {} when Recurrence is not None." ) # Targeting kwargs @@ -106,6 +106,7 @@ def evaluate(self, context: Mapping[Any, Any], **kwargs: Any) -> bool: return (start_time is None or start_time <= current_time) and (end_time is None or current_time < end_time) + @FeatureFilter.alias("Microsoft.Targeting") class TargetingFilter(FeatureFilter): """ diff --git a/featuremanagement/_time_window_filter/_models.py b/featuremanagement/_time_window_filter/_models.py index e31a0fb..32d6ff1 100644 --- a/featuremanagement/_time_window_filter/_models.py +++ b/featuremanagement/_time_window_filter/_models.py @@ -4,8 +4,8 @@ # license information. # ------------------------------------------------------------------------- from enum import Enum +from typing import Dict, Any from datetime import datetime -from typing import Self from dataclasses import dataclass from email.utils import parsedate_to_datetime @@ -18,7 +18,16 @@ class RecurrencePatternType(str, Enum): DAILY = "Daily" WEEKLY = "Weekly" - def from_str(value: str) -> Self: + @staticmethod + def from_str(value: str) -> "RecurrencePatternType": + """ + Get the RecurrencePatternType from the string value. + + :param value: The string value. + :type value: str + :return: The RecurrencePatternType. + :rtype: RecurrencePatternType + """ if value == "Daily": return RecurrencePatternType.DAILY if value == "Weekly": @@ -35,7 +44,16 @@ class RecurrenceRangeType(str, Enum): END_DATE = "EndDate" NUMBERED = "Numbered" - def from_str(value: str) -> Self: + @staticmethod + def from_str(value: str) -> "RecurrenceRangeType": + """ + Get the RecurrenceRangeType from the string value. + + :param value: The string value. + :type value: str + :return: The RecurrenceRangeType. + :rtype: RecurrenceRangeType + """ if value == "NoEnd": return RecurrenceRangeType.NO_END if value == "EndDate": @@ -44,36 +62,40 @@ def from_str(value: str) -> Self: return RecurrenceRangeType.NUMBERED raise ValueError(f"Invalid value: {value}") -class RecurrencePattern: + +class RecurrencePattern: # pylint: disable=too-few-public-methods """ The recurrence pattern settings. """ - def __init__(self, pattern_data: dict[str: any]): + def __init__(self, pattern_data: Dict[str, Any]): self.type = RecurrencePatternType.from_str(pattern_data.get("Type", "Daily")) self.interval = pattern_data.get("Interval", 1) self.days_of_week = pattern_data.get("DaysOfWeek", []) self.first_day_of_week = pattern_data.get("FirstDayOfWeek", 7) -class RecurrenceRange: + +class RecurrenceRange: # pylint: disable=too-few-public-methods """ The recurrence range settings. """ - def __init__(self, range_data: dict[str: any]): + def __init__(self, range_data: Dict[str, Any]): self.type = RecurrenceRangeType.from_str(range_data.get("Type", "NoEnd")) - if range_data.get("EndDate"): - self.end_date = parsedate_to_datetime(range_data.get("EndDate")) + if range_data.get("EndDate") and isinstance(range_data.get("EndDate"), str): + end_date_str = range_data.get("EndDate", "") + self.end_date = parsedate_to_datetime(end_date_str) if end_date_str else None self.num_of_occurrences = range_data.get("NumberOfOccurrences") -class Recurrence: + +class Recurrence: # pylint: disable=too-few-public-methods """ The recurrence settings. """ - def __init__(self, recurrence_data: dict[str: any]): - self.pattern = RecurrencePattern(recurrence_data.get("Pattern")) - self.range = RecurrenceRange(recurrence_data.get("Range")) + def __init__(self, recurrence_data: Dict[str, Any]): + self.pattern = RecurrencePattern(recurrence_data.get("Pattern", {})) + self.range = RecurrenceRange(recurrence_data.get("Range", {})) @dataclass diff --git a/featuremanagement/_time_window_filter/_recurrence_evaluator.py b/featuremanagement/_time_window_filter/_recurrence_evaluator.py index 71f579d..9d894e9 100644 --- a/featuremanagement/_time_window_filter/_recurrence_evaluator.py +++ b/featuremanagement/_time_window_filter/_recurrence_evaluator.py @@ -41,14 +41,18 @@ def _get_previous_occurrence(settings: TimeWindowFilterSettings, now: datetime) recurrence_range = settings.recurrence.range range_type = recurrence_range.type + previous_occurrence = occurrence_info.previous_occurrence + end_date = recurrence_range.end_date if ( range_type == RecurrenceRangeType.END_DATE - and occurrence_info.previous_occurrence - and occurrence_info.previous_occurrence > recurrence_range.end_date + and previous_occurrence is not None + and end_date is not None + and previous_occurrence > end_date ): return None if ( range_type == RecurrenceRangeType.NUMBERED + and recurrence_range.num_of_occurrences is not None and occurrence_info.num_of_occurrences > recurrence_range.num_of_occurrences ): return None diff --git a/featuremanagement/_time_window_filter/_recurrence_validator.py b/featuremanagement/_time_window_filter/_recurrence_validator.py index 509a2ac..eece113 100644 --- a/featuremanagement/_time_window_filter/_recurrence_validator.py +++ b/featuremanagement/_time_window_filter/_recurrence_validator.py @@ -109,7 +109,8 @@ def _validate_days_of_week(settings: TimeWindowFilterSettings) -> None: def _validate_end_date(settings: TimeWindowFilterSettings) -> None: - if settings.recurrence.range.end_date < settings.start: + end_date = settings.recurrence.range.end_date + if end_date and end_date < settings.start: raise ValueError("The Recurrence.Range.EndDate should be after the Start") From 1a79afbe856790b99066c67accb258665198d82f Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Fri, 13 Dec 2024 09:33:39 -0800 Subject: [PATCH 04/32] Review Comments --- featuremanagement/_time_window_filter/_models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/featuremanagement/_time_window_filter/_models.py b/featuremanagement/_time_window_filter/_models.py index 32d6ff1..987a33c 100644 --- a/featuremanagement/_time_window_filter/_models.py +++ b/featuremanagement/_time_window_filter/_models.py @@ -71,6 +71,8 @@ class RecurrencePattern: # pylint: disable=too-few-public-methods def __init__(self, pattern_data: Dict[str, Any]): self.type = RecurrencePatternType.from_str(pattern_data.get("Type", "Daily")) self.interval = pattern_data.get("Interval", 1) + if self.interval <= 0: + raise ValueError("The interval must be greater than 0.") self.days_of_week = pattern_data.get("DaysOfWeek", []) self.first_day_of_week = pattern_data.get("FirstDayOfWeek", 7) @@ -85,7 +87,7 @@ def __init__(self, range_data: Dict[str, Any]): if range_data.get("EndDate") and isinstance(range_data.get("EndDate"), str): end_date_str = range_data.get("EndDate", "") self.end_date = parsedate_to_datetime(end_date_str) if end_date_str else None - self.num_of_occurrences = range_data.get("NumberOfOccurrences") + self.num_of_occurrences = range_data.get("NumberOfOccurrences", 0) class Recurrence: # pylint: disable=too-few-public-methods From 6fcd0701b0290acd33c2f7cf5e504ad06d9f3f3b Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Fri, 13 Dec 2024 09:35:20 -0800 Subject: [PATCH 05/32] Review comments --- featuremanagement/_time_window_filter/_models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/featuremanagement/_time_window_filter/_models.py b/featuremanagement/_time_window_filter/_models.py index 987a33c..bf5c1c6 100644 --- a/featuremanagement/_time_window_filter/_models.py +++ b/featuremanagement/_time_window_filter/_models.py @@ -88,6 +88,8 @@ def __init__(self, range_data: Dict[str, Any]): end_date_str = range_data.get("EndDate", "") self.end_date = parsedate_to_datetime(end_date_str) if end_date_str else None self.num_of_occurrences = range_data.get("NumberOfOccurrences", 0) + if self.num_of_occurrences < 0: + raise ValueError("The number of occurrences must be greater than or equal to 0.") class Recurrence: # pylint: disable=too-few-public-methods From 651eb82fed37c2fbd7e5d4e393b0736a68056d42 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Thu, 19 Dec 2024 14:53:45 -0500 Subject: [PATCH 06/32] Fixing days of the week --- featuremanagement/_time_window_filter/_models.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/featuremanagement/_time_window_filter/_models.py b/featuremanagement/_time_window_filter/_models.py index bf5c1c6..dd3296b 100644 --- a/featuremanagement/_time_window_filter/_models.py +++ b/featuremanagement/_time_window_filter/_models.py @@ -4,7 +4,7 @@ # license information. # ------------------------------------------------------------------------- from enum import Enum -from typing import Dict, Any +from typing import Dict, Any, Optional from datetime import datetime from dataclasses import dataclass from email.utils import parsedate_to_datetime @@ -68,13 +68,18 @@ class RecurrencePattern: # pylint: disable=too-few-public-methods The recurrence pattern settings. """ + days: list = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] + def __init__(self, pattern_data: Dict[str, Any]): self.type = RecurrencePatternType.from_str(pattern_data.get("Type", "Daily")) self.interval = pattern_data.get("Interval", 1) if self.interval <= 0: raise ValueError("The interval must be greater than 0.") - self.days_of_week = pattern_data.get("DaysOfWeek", []) - self.first_day_of_week = pattern_data.get("FirstDayOfWeek", 7) + days_of_week = pattern_data.get("DaysOfWeek", []) + for day in days_of_week: + self.days_of_week.append(self.days.index(day)) + first_day_of_week = pattern_data.get("FirstDayOfWeek", "Sunday") + self.first_day_of_week = self.days.index(first_day_of_week) if first_day_of_week in self.days else 0 class RecurrenceRange: # pylint: disable=too-few-public-methods @@ -110,7 +115,7 @@ class TimeWindowFilterSettings: start: datetime end: datetime - recurrence: Recurrence + recurrence: Optional[Recurrence] @dataclass From e1d4a3ab77244625698e18963c030d646e48cd03 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Tue, 1 Apr 2025 14:19:34 -0700 Subject: [PATCH 07/32] Code review items --- featuremanagement/_defaultfilters.py | 26 +++++++------------ .../_time_window_filter/_models.py | 12 ++++++--- .../_recurrence_evaluator.py | 22 +++++++++++----- .../_recurrence_validator.py | 11 +------- 4 files changed, 34 insertions(+), 37 deletions(-) diff --git a/featuremanagement/_defaultfilters.py b/featuremanagement/_defaultfilters.py index 2c55509..1b78d4e 100644 --- a/featuremanagement/_defaultfilters.py +++ b/featuremanagement/_defaultfilters.py @@ -71,9 +71,6 @@ def evaluate(self, context: Mapping[Any, Any], **kwargs: Any) -> bool: current_time = datetime.now(timezone.utc) - if recurrence_data: - recurrence = Recurrence(recurrence_data) - if not start and not end: logger.warning( TIME_WINDOW_FILTER_INVALID, @@ -87,24 +84,19 @@ def evaluate(self, context: Mapping[Any, Any], **kwargs: Any) -> bool: start_time: Optional[datetime] = parsedate_to_datetime(start) if start else None end_time: Optional[datetime] = parsedate_to_datetime(end) if end else None - if recurrence: - if start_time and end_time: - settings = TimeWindowFilterSettings(start_time, end_time, recurrence) - return is_match(settings, current_time) - logger.warning( - TIME_WINDOW_FILTER_INVALID_RECURRENCE, - TimeWindowFilter.__name__, - context.get(FEATURE_FLAG_NAME_KEY), - START_KEY, - END_KEY, - ) - return False - if not start and not end: logging.warning("%s: At least one of Start or End is required.", TimeWindowFilter.__name__) return False - return (start_time is None or start_time <= current_time) and (end_time is None or current_time < end_time) + if (start_time is None or start_time <= current_time) and (end_time is None or current_time < end_time): + return True + + if recurrence_data: + recurrence = Recurrence(recurrence_data) + settings = TimeWindowFilterSettings(start_time, end_time, recurrence) + return is_match(settings, current_time) + + return False @FeatureFilter.alias("Microsoft.Targeting") diff --git a/featuremanagement/_time_window_filter/_models.py b/featuremanagement/_time_window_filter/_models.py index dd3296b..11da978 100644 --- a/featuremanagement/_time_window_filter/_models.py +++ b/featuremanagement/_time_window_filter/_models.py @@ -3,6 +3,7 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # ------------------------------------------------------------------------- +import math from enum import Enum from typing import Dict, Any, Optional from datetime import datetime @@ -68,7 +69,8 @@ class RecurrencePattern: # pylint: disable=too-few-public-methods The recurrence pattern settings. """ - days: list = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] + days: list = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + days_of_week: list = [] def __init__(self, pattern_data: Dict[str, Any]): self.type = RecurrencePatternType.from_str(pattern_data.get("Type", "Daily")) @@ -78,8 +80,7 @@ def __init__(self, pattern_data: Dict[str, Any]): days_of_week = pattern_data.get("DaysOfWeek", []) for day in days_of_week: self.days_of_week.append(self.days.index(day)) - first_day_of_week = pattern_data.get("FirstDayOfWeek", "Sunday") - self.first_day_of_week = self.days.index(first_day_of_week) if first_day_of_week in self.days else 0 + self.first_day_of_week = self.days.index(pattern_data.get("FirstDayOfWeek", "Sunday")) class RecurrenceRange: # pylint: disable=too-few-public-methods @@ -87,12 +88,15 @@ class RecurrenceRange: # pylint: disable=too-few-public-methods The recurrence range settings. """ + type: RecurrenceRangeType + end_date: Optional[datetime] = None + def __init__(self, range_data: Dict[str, Any]): self.type = RecurrenceRangeType.from_str(range_data.get("Type", "NoEnd")) if range_data.get("EndDate") and isinstance(range_data.get("EndDate"), str): end_date_str = range_data.get("EndDate", "") self.end_date = parsedate_to_datetime(end_date_str) if end_date_str else None - self.num_of_occurrences = range_data.get("NumberOfOccurrences", 0) + self.num_of_occurrences = range_data.get("NumberOfOccurrences", math.pow(2, 63) - 1) if self.num_of_occurrences < 0: raise ValueError("The number of occurrences must be greater than or equal to 0.") diff --git a/featuremanagement/_time_window_filter/_recurrence_evaluator.py b/featuremanagement/_time_window_filter/_recurrence_evaluator.py index 9d894e9..cc28c4f 100644 --- a/featuremanagement/_time_window_filter/_recurrence_evaluator.py +++ b/featuremanagement/_time_window_filter/_recurrence_evaluator.py @@ -8,6 +8,7 @@ from ._models import RecurrencePatternType, RecurrenceRangeType, TimeWindowFilterSettings, OccurrenceInfo from ._recurrence_validator import validate_settings +DAYS_PER_WEEK = 7 def is_match(settings: TimeWindowFilterSettings, now: datetime) -> bool: """ @@ -36,8 +37,10 @@ def _get_previous_occurrence(settings: TimeWindowFilterSettings, now: datetime) pattern_type = settings.recurrence.pattern.type if pattern_type == RecurrencePatternType.DAILY: occurrence_info = _get_daily_previous_occurrence(settings, now) - else: + elif pattern_type == RecurrencePatternType.WEEKLY: occurrence_info = _get_weekly_previous_occurrence(settings, now) + else: + raise ValueError(f"Invalid recurrence pattern type: %s", pattern_type) recurrence_range = settings.recurrence.range range_type = recurrence_range.type @@ -74,16 +77,16 @@ def _get_weekly_previous_occurrence(settings: TimeWindowFilterSettings, now: dat start = settings.start first_day_of_first_week = start - timedelta(days=_get_passed_week_days(start.weekday(), pattern.first_day_of_week)) - number_of_interval = (now - first_day_of_first_week).days // (interval * 7) + number_of_interval = (now - first_day_of_first_week).days // (interval * DAYS_PER_WEEK) first_day_of_most_recent_occurring_week = first_day_of_first_week + timedelta( - days=number_of_interval * (interval * 7) + days=number_of_interval * (interval * DAYS_PER_WEEK) ) sorted_days_of_week = _sort_days_of_week(pattern.days_of_week, pattern.first_day_of_week) max_day_offset = _get_passed_week_days(sorted_days_of_week[-1], pattern.first_day_of_week) min_day_offset = _get_passed_week_days(sorted_days_of_week[0], pattern.first_day_of_week) num_of_occurrences = number_of_interval * len(sorted_days_of_week) - sorted_days_of_week.index(start.weekday()) - if now > first_day_of_most_recent_occurring_week + timedelta(days=7): + if now > first_day_of_most_recent_occurring_week + timedelta(days=DAYS_PER_WEEK): num_of_occurrences += len(sorted_days_of_week) most_recent_occurrence = first_day_of_most_recent_occurring_week + timedelta(days=max_day_offset) return OccurrenceInfo(most_recent_occurrence, num_of_occurrences) @@ -94,7 +97,7 @@ def _get_weekly_previous_occurrence(settings: TimeWindowFilterSettings, now: dat day_with_min_offset = start if now < day_with_min_offset: most_recent_occurrence = ( - first_day_of_most_recent_occurring_week - timedelta(days=interval * 7) + timedelta(days=max_day_offset) + first_day_of_most_recent_occurring_week - timedelta(days=interval * DAYS_PER_WEEK) + timedelta(days=max_day_offset) ) else: most_recent_occurrence = day_with_min_offset @@ -113,7 +116,14 @@ def _get_weekly_previous_occurrence(settings: TimeWindowFilterSettings, now: dat def _get_passed_week_days(current_day: int, first_day_of_week: int) -> int: - return (current_day - first_day_of_week + 7) % 7 + """ + Get the number of days passed since the first day of the week. + :param int current_day: The current day of the week (0-6). + :param int first_day_of_week: The first day of the week (0-6). + :return: The number of days passed since the first day of the week. + :rtype: int + """ + return (current_day - first_day_of_week + DAYS_PER_WEEK) % DAYS_PER_WEEK def _sort_days_of_week(days_of_week: List[int], first_day_of_week: int) -> List[int]: diff --git a/featuremanagement/_time_window_filter/_recurrence_validator.py b/featuremanagement/_time_window_filter/_recurrence_validator.py index eece113..0854357 100644 --- a/featuremanagement/_time_window_filter/_recurrence_validator.py +++ b/featuremanagement/_time_window_filter/_recurrence_validator.py @@ -4,8 +4,8 @@ # license information. # ------------------------------------------------------------------------- from datetime import datetime, timedelta -from typing import List from ._models import RecurrencePatternType, RecurrenceRangeType, TimeWindowFilterSettings +from ._recurrence_evaluator import _get_passed_week_days, _sort_days_of_week DAYS_PER_WEEK = 7 @@ -151,12 +151,3 @@ def _is_duration_compliant_with_days_of_week(settings: TimeWindowFilterSettings) time_window_duration = settings.end - settings.start return min_gap >= time_window_duration - - -def _get_passed_week_days(today: int, first_day_of_week: int) -> int: - return (today - first_day_of_week + DAYS_PER_WEEK) % DAYS_PER_WEEK - - -def _sort_days_of_week(days_of_week: List[int], first_day_of_week: int) -> List[int]: - sorted_days = sorted(days_of_week, key=lambda day: _get_passed_week_days(day, first_day_of_week)) - return sorted_days From c5e6e84eea4e3c3eafdd2dab7b6058b0e9275373 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Tue, 1 Apr 2025 14:39:09 -0700 Subject: [PATCH 08/32] Updating to deal with optional start/end --- .../_time_window_filter/_models.py | 13 ++- .../_recurrence_evaluator.py | 27 ++--- .../_recurrence_validator.py | 100 +++++++++++------- 3 files changed, 78 insertions(+), 62 deletions(-) diff --git a/featuremanagement/_time_window_filter/_models.py b/featuremanagement/_time_window_filter/_models.py index 11da978..cffb05d 100644 --- a/featuremanagement/_time_window_filter/_models.py +++ b/featuremanagement/_time_window_filter/_models.py @@ -5,7 +5,7 @@ # ------------------------------------------------------------------------- import math from enum import Enum -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, List from datetime import datetime from dataclasses import dataclass from email.utils import parsedate_to_datetime @@ -69,8 +69,8 @@ class RecurrencePattern: # pylint: disable=too-few-public-methods The recurrence pattern settings. """ - days: list = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] - days_of_week: list = [] + days: List[str] = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + days_of_week: List[int] = [] def __init__(self, pattern_data: Dict[str, Any]): self.type = RecurrencePatternType.from_str(pattern_data.get("Type", "Daily")) @@ -106,6 +106,9 @@ class Recurrence: # pylint: disable=too-few-public-methods The recurrence settings. """ + pattern: RecurrencePattern + range: RecurrenceRange + def __init__(self, recurrence_data: Dict[str, Any]): self.pattern = RecurrencePattern(recurrence_data.get("Pattern", {})) self.range = RecurrenceRange(recurrence_data.get("Range", {})) @@ -117,8 +120,8 @@ class TimeWindowFilterSettings: The settings for the time window filter. """ - start: datetime - end: datetime + start: Optional[datetime] + end: Optional[datetime] recurrence: Optional[Recurrence] diff --git a/featuremanagement/_time_window_filter/_recurrence_evaluator.py b/featuremanagement/_time_window_filter/_recurrence_evaluator.py index cc28c4f..1218e9f 100644 --- a/featuremanagement/_time_window_filter/_recurrence_evaluator.py +++ b/featuremanagement/_time_window_filter/_recurrence_evaluator.py @@ -4,12 +4,13 @@ # license information. # ------------------------------------------------------------------------- from datetime import datetime, timedelta -from typing import List, Optional +from typing import Optional from ._models import RecurrencePatternType, RecurrenceRangeType, TimeWindowFilterSettings, OccurrenceInfo -from ._recurrence_validator import validate_settings +from ._recurrence_validator import validate_settings, _get_passed_week_days, _sort_days_of_week DAYS_PER_WEEK = 7 + def is_match(settings: TimeWindowFilterSettings, now: datetime) -> bool: """ Check if the current time is within the time window filter settings. @@ -40,7 +41,7 @@ def _get_previous_occurrence(settings: TimeWindowFilterSettings, now: datetime) elif pattern_type == RecurrencePatternType.WEEKLY: occurrence_info = _get_weekly_previous_occurrence(settings, now) else: - raise ValueError(f"Invalid recurrence pattern type: %s", pattern_type) + raise ValueError(f"Invalid recurrence pattern type: {pattern_type}") recurrence_range = settings.recurrence.range range_type = recurrence_range.type @@ -97,7 +98,9 @@ def _get_weekly_previous_occurrence(settings: TimeWindowFilterSettings, now: dat day_with_min_offset = start if now < day_with_min_offset: most_recent_occurrence = ( - first_day_of_most_recent_occurring_week - timedelta(days=interval * DAYS_PER_WEEK) + timedelta(days=max_day_offset) + first_day_of_most_recent_occurring_week + - timedelta(days=interval * DAYS_PER_WEEK) + + timedelta(days=max_day_offset) ) else: most_recent_occurrence = day_with_min_offset @@ -113,19 +116,3 @@ def _get_weekly_previous_occurrence(settings: TimeWindowFilterSettings, now: dat num_of_occurrences += 1 return OccurrenceInfo(most_recent_occurrence, num_of_occurrences) - - -def _get_passed_week_days(current_day: int, first_day_of_week: int) -> int: - """ - Get the number of days passed since the first day of the week. - :param int current_day: The current day of the week (0-6). - :param int first_day_of_week: The first day of the week (0-6). - :return: The number of days passed since the first day of the week. - :rtype: int - """ - return (current_day - first_day_of_week + DAYS_PER_WEEK) % DAYS_PER_WEEK - - -def _sort_days_of_week(days_of_week: List[int], first_day_of_week: int) -> List[int]: - sorted_days = sorted(days_of_week) - return sorted_days[sorted_days.index(first_day_of_week) :] + sorted_days[: sorted_days.index(first_day_of_week)] diff --git a/featuremanagement/_time_window_filter/_recurrence_validator.py b/featuremanagement/_time_window_filter/_recurrence_validator.py index 0854357..04f24d0 100644 --- a/featuremanagement/_time_window_filter/_recurrence_validator.py +++ b/featuremanagement/_time_window_filter/_recurrence_validator.py @@ -4,8 +4,8 @@ # license information. # ------------------------------------------------------------------------- from datetime import datetime, timedelta -from ._models import RecurrencePatternType, RecurrenceRangeType, TimeWindowFilterSettings -from ._recurrence_evaluator import _get_passed_week_days, _sort_days_of_week +from typing import List +from ._models import RecurrencePatternType, RecurrenceRangeType, TimeWindowFilterSettings, Recurrence DAYS_PER_WEEK = 7 @@ -27,13 +27,21 @@ def validate_settings(settings: TimeWindowFilterSettings) -> None: :param TimeWindowFilterSettings settings: The settings for the time window filter. :raises ValueError: If the settings are invalid. """ - _validate_recurrence_required_parameter(settings) - _validate_recurrence_pattern(settings) - _validate_recurrence_range(settings) + recurrence = settings.recurrence + if recurrence is None: + raise ValueError(REQUIRED_PARAMETER % "Recurrence") + start = settings.start + end = settings.end + if start is None or end is None: + raise ValueError(REQUIRED_PARAMETER % "Start or End") -def _validate_recurrence_required_parameter(settings: TimeWindowFilterSettings) -> None: - recurrence = settings.recurrence + _validate_recurrence_required_parameter(recurrence, start, end) + _validate_recurrence_pattern(recurrence, start, end) + _validate_recurrence_range(recurrence, start) + + +def _validate_recurrence_required_parameter(recurrence: Recurrence, start: datetime, end: datetime) -> None: param_name = "" reason = "" if recurrence.pattern is None: @@ -42,10 +50,10 @@ def _validate_recurrence_required_parameter(settings: TimeWindowFilterSettings) if recurrence.range is None: param_name = f"{RECURRENCE_RANGE}" reason = REQUIRED_PARAMETER - if not settings.end > settings.start: + if not end > start: param_name = "end" reason = OUT_OF_RANGE - if settings.end > settings.start + timedelta(days=TEN_YEARS): + if end > start + timedelta(days=TEN_YEARS): param_name = "end" reason = TIME_WINDOW_DURATION_TEN_YEARS @@ -53,75 +61,77 @@ def _validate_recurrence_required_parameter(settings: TimeWindowFilterSettings) raise ValueError(reason % param_name) -def _validate_recurrence_pattern(settings: TimeWindowFilterSettings) -> None: - pattern_type = settings.recurrence.pattern.type +def _validate_recurrence_pattern(recurrence: Recurrence, start: datetime, end: datetime) -> None: + if recurrence is None: + raise ValueError(REQUIRED_PARAMETER % "Recurrence") + pattern_type = recurrence.pattern.type if pattern_type == RecurrencePatternType.DAILY: - _validate_daily_recurrence_pattern(settings) + _validate_daily_recurrence_pattern(recurrence, start, end) else: - _validate_weekly_recurrence_pattern(settings) + _validate_weekly_recurrence_pattern(recurrence, start, end) -def _validate_recurrence_range(settings: TimeWindowFilterSettings) -> None: - range_type = settings.recurrence.range.type +def _validate_recurrence_range(recurrence: Recurrence, start: datetime) -> None: + range_type = recurrence.range.type if range_type == RecurrenceRangeType.END_DATE: - _validate_end_date(settings) + _validate_end_date(recurrence, start) -def _validate_daily_recurrence_pattern(settings: TimeWindowFilterSettings) -> None: +def _validate_daily_recurrence_pattern(recurrence: Recurrence, start: datetime, end: datetime) -> None: # "Start" is always a valid first occurrence for "Daily" pattern. # Only need to check if time window validated - _validate_time_window_duration(settings) + _validate_time_window_duration(recurrence, start, end) -def _validate_weekly_recurrence_pattern(settings: TimeWindowFilterSettings) -> None: - _validate_days_of_week(settings) +def _validate_weekly_recurrence_pattern(recurrence: Recurrence, start: datetime, end: datetime) -> None: + _validate_days_of_week(recurrence) # Check whether "Start" is a valid first occurrence - pattern = settings.recurrence.pattern - if settings.start.weekday() not in pattern.days_of_week: + pattern = recurrence.pattern + if start.weekday() not in pattern.days_of_week: raise ValueError(NOT_MATCHED % "start") # Time window duration must be shorter than how frequently it occurs - _validate_time_window_duration(settings) + _validate_time_window_duration(recurrence, start, end) # Check whether the time window duration is shorter than the minimum gap between days of week - if not _is_duration_compliant_with_days_of_week(settings): + if not _is_duration_compliant_with_days_of_week(recurrence, start, end): raise ValueError(TIME_WINDOW_DURATION_OUT_OF_RANGE % "Recurrence.Pattern.DaysOfWeek") -def _validate_time_window_duration(settings: TimeWindowFilterSettings) -> None: - pattern = settings.recurrence.pattern +def _validate_time_window_duration(recurrence: Recurrence, start: datetime, end: datetime) -> None: + pattern = recurrence.pattern interval_duration = ( timedelta(days=pattern.interval) if pattern.type == RecurrencePatternType.DAILY else timedelta(days=pattern.interval * DAYS_PER_WEEK) ) - time_window_duration = settings.end - settings.start + time_window_duration = end - start if time_window_duration > interval_duration: raise ValueError(TIME_WINDOW_DURATION_OUT_OF_RANGE % "Recurrence.Pattern.Interval") -def _validate_days_of_week(settings: TimeWindowFilterSettings) -> None: - days_of_week = settings.recurrence.pattern.days_of_week +def _validate_days_of_week(recurrence: Recurrence) -> None: + days_of_week = recurrence.pattern.days_of_week if not days_of_week: raise ValueError(REQUIRED_PARAMETER % "Recurrence.Pattern.DaysOfWeek") -def _validate_end_date(settings: TimeWindowFilterSettings) -> None: - end_date = settings.recurrence.range.end_date - if end_date and end_date < settings.start: +def _validate_end_date(recurrence: Recurrence, start: datetime) -> None: + end_date = recurrence.range.end_date + if end_date and end_date < start: raise ValueError("The Recurrence.Range.EndDate should be after the Start") -def _is_duration_compliant_with_days_of_week(settings: TimeWindowFilterSettings) -> bool: - days_of_week = settings.recurrence.pattern.days_of_week +def _is_duration_compliant_with_days_of_week(recurrence: Recurrence, start: datetime, end: datetime) -> bool: + days_of_week = recurrence.pattern.days_of_week if len(days_of_week) == 1: return True # Get the date of first day of the week today = datetime.now() - first_day_of_week = settings.recurrence.pattern.first_day_of_week + first_day_of_week = recurrence.pattern.first_day_of_week offset = _get_passed_week_days(today.weekday(), first_day_of_week) first_date_of_week = today - timedelta(days=offset) sorted_days_of_week = _sort_days_of_week(days_of_week, first_day_of_week) @@ -137,7 +147,7 @@ def _is_duration_compliant_with_days_of_week(settings: TimeWindowFilterSettings) min_gap = min(min_gap, current_gap) prev_occurrence = date - if settings.recurrence.pattern.interval == 1: + if recurrence.pattern.interval == 1: # It may cross weeks. Check the adjacent week date = first_date_of_week + timedelta( days=DAYS_PER_WEEK + _get_passed_week_days(sorted_days_of_week[0], first_day_of_week) @@ -149,5 +159,21 @@ def _is_duration_compliant_with_days_of_week(settings: TimeWindowFilterSettings) current_gap = date - prev_occurrence min_gap = min(min_gap, current_gap) - time_window_duration = settings.end - settings.start + time_window_duration = end - start return min_gap >= time_window_duration + + +def _get_passed_week_days(current_day: int, first_day_of_week: int) -> int: + """ + Get the number of days passed since the first day of the week. + :param int current_day: The current day of the week, where Monday == 0 ... Sunday == 6. + :param int first_day_of_week: The first day of the week (0-6), where Monday == 0 ... Sunday == 6. + :return: The number of days passed since the first day of the week. + :rtype: int + """ + return (current_day - first_day_of_week + DAYS_PER_WEEK) % DAYS_PER_WEEK + + +def _sort_days_of_week(days_of_week: List[int], first_day_of_week: int) -> List[int]: + sorted_days = sorted(days_of_week) + return sorted_days[sorted_days.index(first_day_of_week) :] + sorted_days[: sorted_days.index(first_day_of_week)] From 7f816a9be157dce31b268bc3f2fe2b86af16731b Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Tue, 1 Apr 2025 15:00:46 -0700 Subject: [PATCH 09/32] Updated settings usage --- .../_recurrence_evaluator.py | 39 +++++++++++-------- .../_recurrence_validator.py | 13 +------ 2 files changed, 25 insertions(+), 27 deletions(-) diff --git a/featuremanagement/_time_window_filter/_recurrence_evaluator.py b/featuremanagement/_time_window_filter/_recurrence_evaluator.py index 1218e9f..b5f22f6 100644 --- a/featuremanagement/_time_window_filter/_recurrence_evaluator.py +++ b/featuremanagement/_time_window_filter/_recurrence_evaluator.py @@ -5,10 +5,11 @@ # ------------------------------------------------------------------------- from datetime import datetime, timedelta from typing import Optional -from ._models import RecurrencePatternType, RecurrenceRangeType, TimeWindowFilterSettings, OccurrenceInfo +from ._models import RecurrencePatternType, RecurrenceRangeType, TimeWindowFilterSettings, OccurrenceInfo, Recurrence from ._recurrence_validator import validate_settings, _get_passed_week_days, _sort_days_of_week DAYS_PER_WEEK = 7 +REQUIRED_PARAMETER = "Required parameter: %s" def is_match(settings: TimeWindowFilterSettings, now: datetime) -> bool: @@ -20,30 +21,38 @@ def is_match(settings: TimeWindowFilterSettings, now: datetime) -> bool: :return: True if the current time is within the time window filter settings, otherwise False. :rtype: bool """ - validate_settings(settings) + recurrence = settings.recurrence + if recurrence is None: + raise ValueError(REQUIRED_PARAMETER % "Recurrence") - previous_occurrence = _get_previous_occurrence(settings, now) + start = settings.start + end = settings.end + if start is None or end is None: + raise ValueError(REQUIRED_PARAMETER % "Start or End") + + validate_settings(recurrence, start, end) + + previous_occurrence = _get_previous_occurrence(recurrence, start, now) if previous_occurrence is None: return False - occurrence_end_date = previous_occurrence + (settings.end - settings.start) + occurrence_end_date = previous_occurrence + (end - start) return now < occurrence_end_date -def _get_previous_occurrence(settings: TimeWindowFilterSettings, now: datetime) -> Optional[datetime]: - start = settings.start +def _get_previous_occurrence(recurrence: Recurrence, start: datetime, now: datetime) -> Optional[datetime]: if now < start: return None - pattern_type = settings.recurrence.pattern.type + pattern_type = recurrence.pattern.type if pattern_type == RecurrencePatternType.DAILY: - occurrence_info = _get_daily_previous_occurrence(settings, now) + occurrence_info = _get_daily_previous_occurrence(recurrence, start, now) elif pattern_type == RecurrencePatternType.WEEKLY: - occurrence_info = _get_weekly_previous_occurrence(settings, now) + occurrence_info = _get_weekly_previous_occurrence(recurrence, start, now) else: raise ValueError(f"Invalid recurrence pattern type: {pattern_type}") - recurrence_range = settings.recurrence.range + recurrence_range = recurrence.range range_type = recurrence_range.type previous_occurrence = occurrence_info.previous_occurrence end_date = recurrence_range.end_date @@ -64,18 +73,16 @@ def _get_previous_occurrence(settings: TimeWindowFilterSettings, now: datetime) return occurrence_info.previous_occurrence -def _get_daily_previous_occurrence(settings: TimeWindowFilterSettings, now: datetime) -> OccurrenceInfo: - start = settings.start - interval = settings.recurrence.pattern.interval +def _get_daily_previous_occurrence(recurrence: Recurrence, start: datetime, now: datetime) -> OccurrenceInfo: + interval = recurrence.pattern.interval num_of_occurrences = (now - start).days // interval previous_occurrence = start + timedelta(days=num_of_occurrences * interval) return OccurrenceInfo(previous_occurrence, num_of_occurrences + 1) -def _get_weekly_previous_occurrence(settings: TimeWindowFilterSettings, now: datetime) -> OccurrenceInfo: - pattern = settings.recurrence.pattern +def _get_weekly_previous_occurrence(recurrence: Recurrence, start: datetime, now: datetime) -> OccurrenceInfo: + pattern = recurrence.pattern interval = pattern.interval - start = settings.start first_day_of_first_week = start - timedelta(days=_get_passed_week_days(start.weekday(), pattern.first_day_of_week)) number_of_interval = (now - first_day_of_first_week).days // (interval * DAYS_PER_WEEK) diff --git a/featuremanagement/_time_window_filter/_recurrence_validator.py b/featuremanagement/_time_window_filter/_recurrence_validator.py index 04f24d0..a1ec86a 100644 --- a/featuremanagement/_time_window_filter/_recurrence_validator.py +++ b/featuremanagement/_time_window_filter/_recurrence_validator.py @@ -5,7 +5,7 @@ # ------------------------------------------------------------------------- from datetime import datetime, timedelta from typing import List -from ._models import RecurrencePatternType, RecurrenceRangeType, TimeWindowFilterSettings, Recurrence +from ._models import RecurrencePatternType, RecurrenceRangeType, Recurrence DAYS_PER_WEEK = 7 @@ -20,22 +20,13 @@ TIME_WINDOW_DURATION_OUT_OF_RANGE = "Time window duration is out of range: %s" -def validate_settings(settings: TimeWindowFilterSettings) -> None: +def validate_settings(recurrence: Recurrence, start: datetime, end: datetime) -> None: """ Validate the settings for the time window filter. :param TimeWindowFilterSettings settings: The settings for the time window filter. :raises ValueError: If the settings are invalid. """ - recurrence = settings.recurrence - if recurrence is None: - raise ValueError(REQUIRED_PARAMETER % "Recurrence") - - start = settings.start - end = settings.end - if start is None or end is None: - raise ValueError(REQUIRED_PARAMETER % "Start or End") - _validate_recurrence_required_parameter(recurrence, start, end) _validate_recurrence_pattern(recurrence, start, end) _validate_recurrence_range(recurrence, start) From 4759ec8e76683a898788b1e7fd7d580492cb273e Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Thu, 3 Apr 2025 09:22:29 -0700 Subject: [PATCH 10/32] adding tests and fixes --- .../_time_window_filter/_models.py | 10 ++- .../_recurrence_validator.py | 86 ++++++++---------- .../test_recurrence_validator.py | 88 +++++++++++++++++++ 3 files changed, 132 insertions(+), 52 deletions(-) create mode 100644 tests/time_window_filter/test_recurrence_validator.py diff --git a/featuremanagement/_time_window_filter/_models.py b/featuremanagement/_time_window_filter/_models.py index cffb05d..0459ec0 100644 --- a/featuremanagement/_time_window_filter/_models.py +++ b/featuremanagement/_time_window_filter/_models.py @@ -70,15 +70,19 @@ class RecurrencePattern: # pylint: disable=too-few-public-methods """ days: List[str] = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] - days_of_week: List[int] = [] def __init__(self, pattern_data: Dict[str, Any]): self.type = RecurrencePatternType.from_str(pattern_data.get("Type", "Daily")) self.interval = pattern_data.get("Interval", 1) if self.interval <= 0: raise ValueError("The interval must be greater than 0.") - days_of_week = pattern_data.get("DaysOfWeek", []) - for day in days_of_week: + # Days of the week are represented as a list of strings of there names. + days_of_week_str = pattern_data.get("DaysOfWeek", []) + + # Days of the week are represented as a list of integers from 0 to 6. + # 0 = Sunday, 1 = Monday, ..., 6 = Saturday + self.days_of_week = [] + for day in days_of_week_str: self.days_of_week.append(self.days.index(day)) self.first_day_of_week = self.days.index(pattern_data.get("FirstDayOfWeek", "Sunday")) diff --git a/featuremanagement/_time_window_filter/_recurrence_validator.py b/featuremanagement/_time_window_filter/_recurrence_validator.py index a1ec86a..67bd2a4 100644 --- a/featuremanagement/_time_window_filter/_recurrence_validator.py +++ b/featuremanagement/_time_window_filter/_recurrence_validator.py @@ -5,7 +5,7 @@ # ------------------------------------------------------------------------- from datetime import datetime, timedelta from typing import List -from ._models import RecurrencePatternType, RecurrenceRangeType, Recurrence +from ._models import RecurrencePatternType, RecurrenceRangeType, Recurrence, RecurrencePattern, RecurrenceRange DAYS_PER_WEEK = 7 @@ -27,102 +27,90 @@ def validate_settings(recurrence: Recurrence, start: datetime, end: datetime) -> :param TimeWindowFilterSettings settings: The settings for the time window filter. :raises ValueError: If the settings are invalid. """ - _validate_recurrence_required_parameter(recurrence, start, end) - _validate_recurrence_pattern(recurrence, start, end) - _validate_recurrence_range(recurrence, start) - - -def _validate_recurrence_required_parameter(recurrence: Recurrence, start: datetime, end: datetime) -> None: - param_name = "" - reason = "" - if recurrence.pattern is None: - param_name = f"{RECURRENCE_PATTERN}" - reason = REQUIRED_PARAMETER - if recurrence.range is None: - param_name = f"{RECURRENCE_RANGE}" - reason = REQUIRED_PARAMETER - if not end > start: - param_name = "end" - reason = OUT_OF_RANGE - if end > start + timedelta(days=TEN_YEARS): - param_name = "end" - reason = TIME_WINDOW_DURATION_TEN_YEARS + if not recurrence: + raise ValueError("Recurrence is required") + + _validate_start_end_parameter(start, end) + _validate_recurrence_pattern(recurrence.pattern, start, end) + _validate_recurrence_range(recurrence.range, start) + - if param_name: - raise ValueError(reason % param_name) +def _validate_start_end_parameter(start: datetime, end: datetime) -> None: + param_name = "end" + if end > start + timedelta(days=TEN_YEARS): + raise ValueError(TIME_WINDOW_DURATION_TEN_YEARS % param_name) -def _validate_recurrence_pattern(recurrence: Recurrence, start: datetime, end: datetime) -> None: - if recurrence is None: - raise ValueError(REQUIRED_PARAMETER % "Recurrence") - pattern_type = recurrence.pattern.type +def _validate_recurrence_pattern(pattern: RecurrencePattern, start: datetime, end: datetime) -> None: + pattern_type = pattern.type if pattern_type == RecurrencePatternType.DAILY: - _validate_daily_recurrence_pattern(recurrence, start, end) + _validate_daily_recurrence_pattern(pattern, start, end) else: - _validate_weekly_recurrence_pattern(recurrence, start, end) + _validate_weekly_recurrence_pattern(pattern, start, end) -def _validate_recurrence_range(recurrence: Recurrence, start: datetime) -> None: - range_type = recurrence.range.type +def _validate_recurrence_range(recurrence_range: RecurrenceRange, start: datetime) -> None: + range_type = recurrence_range.type if range_type == RecurrenceRangeType.END_DATE: - _validate_end_date(recurrence, start) + _validate_end_date(recurrence_range, start) -def _validate_daily_recurrence_pattern(recurrence: Recurrence, start: datetime, end: datetime) -> None: +def _validate_daily_recurrence_pattern(pattern: RecurrencePattern, start: datetime, end: datetime) -> None: # "Start" is always a valid first occurrence for "Daily" pattern. # Only need to check if time window validated - _validate_time_window_duration(recurrence, start, end) + _validate_time_window_duration(pattern, start, end) -def _validate_weekly_recurrence_pattern(recurrence: Recurrence, start: datetime, end: datetime) -> None: - _validate_days_of_week(recurrence) +def _validate_weekly_recurrence_pattern(pattern: RecurrencePattern, start: datetime, end: datetime) -> None: + _validate_days_of_week(pattern) # Check whether "Start" is a valid first occurrence - pattern = recurrence.pattern if start.weekday() not in pattern.days_of_week: raise ValueError(NOT_MATCHED % "start") # Time window duration must be shorter than how frequently it occurs - _validate_time_window_duration(recurrence, start, end) + _validate_time_window_duration(pattern, start, end) # Check whether the time window duration is shorter than the minimum gap between days of week - if not _is_duration_compliant_with_days_of_week(recurrence, start, end): + if not _is_duration_compliant_with_days_of_week(pattern, start, end): raise ValueError(TIME_WINDOW_DURATION_OUT_OF_RANGE % "Recurrence.Pattern.DaysOfWeek") -def _validate_time_window_duration(recurrence: Recurrence, start: datetime, end: datetime) -> None: - pattern = recurrence.pattern +def _validate_time_window_duration(pattern: RecurrencePattern, start: datetime, end: datetime) -> None: interval_duration = ( timedelta(days=pattern.interval) if pattern.type == RecurrencePatternType.DAILY else timedelta(days=pattern.interval * DAYS_PER_WEEK) ) time_window_duration = end - start + if start > end: + raise ValueError(OUT_OF_RANGE % "The filter start date Start needs to before the End date.") + if time_window_duration > interval_duration: raise ValueError(TIME_WINDOW_DURATION_OUT_OF_RANGE % "Recurrence.Pattern.Interval") -def _validate_days_of_week(recurrence: Recurrence) -> None: - days_of_week = recurrence.pattern.days_of_week +def _validate_days_of_week(pattern: RecurrencePattern) -> None: + days_of_week = pattern.days_of_week if not days_of_week: raise ValueError(REQUIRED_PARAMETER % "Recurrence.Pattern.DaysOfWeek") -def _validate_end_date(recurrence: Recurrence, start: datetime) -> None: - end_date = recurrence.range.end_date +def _validate_end_date(recurrence_range: RecurrenceRange, start: datetime) -> None: + end_date = recurrence_range.end_date if end_date and end_date < start: raise ValueError("The Recurrence.Range.EndDate should be after the Start") -def _is_duration_compliant_with_days_of_week(recurrence: Recurrence, start: datetime, end: datetime) -> bool: - days_of_week = recurrence.pattern.days_of_week +def _is_duration_compliant_with_days_of_week(pattern: RecurrencePattern, start: datetime, end: datetime) -> bool: + days_of_week = pattern.days_of_week if len(days_of_week) == 1: return True # Get the date of first day of the week today = datetime.now() - first_day_of_week = recurrence.pattern.first_day_of_week + first_day_of_week = pattern.first_day_of_week offset = _get_passed_week_days(today.weekday(), first_day_of_week) first_date_of_week = today - timedelta(days=offset) sorted_days_of_week = _sort_days_of_week(days_of_week, first_day_of_week) @@ -138,7 +126,7 @@ def _is_duration_compliant_with_days_of_week(recurrence: Recurrence, start: date min_gap = min(min_gap, current_gap) prev_occurrence = date - if recurrence.pattern.interval == 1: + if pattern.interval == 1: # It may cross weeks. Check the adjacent week date = first_date_of_week + timedelta( days=DAYS_PER_WEEK + _get_passed_week_days(sorted_days_of_week[0], first_day_of_week) diff --git a/tests/time_window_filter/test_recurrence_validator.py b/tests/time_window_filter/test_recurrence_validator.py new file mode 100644 index 0000000..6de60c9 --- /dev/null +++ b/tests/time_window_filter/test_recurrence_validator.py @@ -0,0 +1,88 @@ +import pytest +from datetime import datetime, timedelta +from featuremanagement._time_window_filter._models import Recurrence +from featuremanagement._time_window_filter._recurrence_validator import validate_settings + +def valid_daily_pattern(): + return { + "Type": "Daily", + "Interval": 1, + "DaysOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], + "FirstDayOfWeek": "Sunday", + } + +def valid_daily_recurrence(): + return Recurrence( + { + "Pattern": valid_daily_pattern(), + "Range": {"Type": "NoEnd", "EndDate": None, "NumberOfOccurrences": 10}, + } + ) + +def valid_daily_end_date_recurrence(): + return Recurrence( + { + "Pattern": valid_daily_pattern(), + "Range": {"Type": "EndDate", "EndDate": datetime.now() + timedelta(days=10), "NumberOfOccurrences": 10}, + } + ) + +def valid_weekly_recurrence(): + return Recurrence( + { + "Pattern": { + "Type": "Weekly", + "Interval": 1, + "DaysOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], + "FirstDayOfWeek": "Monday", + }, + "Range": {"Type": "NoEnd", "EndDate": None, "NumberOfOccurrences": 10}, + } + ) + + +def test_validate_settings_valid_daily(): + start = datetime.now() + end = start + timedelta(days=1) + validate_settings(valid_daily_recurrence(), start, end) + +def test_validate_settings_valid_daily_end_date(): + start = datetime.now() + end = start + timedelta(days=1) + validate_settings(valid_daily_end_date_recurrence(), start, end) + + +def test_validate_settings_valid_weekly(): + start = datetime.now() + end = start + timedelta(days=1) + validate_settings(valid_weekly_recurrence(), start, end) + +def test_validate_settings_duration_exceeds_ten_years(): + start = datetime.now() + end = start + timedelta(days=3651) + with pytest.raises(ValueError, match="Time window duration exceeds ten years: end"): + validate_settings(valid_daily_recurrence(), start, end) + +def test_validate_settings_invalid_start_end(): + start = datetime.now() + timedelta(days=1) + end = datetime.now() + with pytest.raises(ValueError, match="The filter start date Start needs to before the End date."): + validate_settings(valid_daily_recurrence(), start, end) + +def test_validate_settings_end_date_in_past(): + start = datetime.now() + end = datetime.now() - timedelta(days=1) + with pytest.raises(ValueError, match="The filter start date Start needs to before the End date."): + validate_settings(valid_daily_recurrence(), start, end) + +def test_validate_settings_missing_recurrence(): + start = datetime.now() + end = start + timedelta(days=1) + with pytest.raises(ValueError, match="Recurrence is required"): + validate_settings(None, start, end) + +def test_validate_settings_invalid_recurrence_pattern(): + with pytest.raises(ValueError, match="Invalid value: InvalidType"): + Recurrence({"Pattern": {"Type": "InvalidType"}, "Range": {"Type": "NoEnd"}}) + + From ab57af1071b1646be13ec9cd803c50d31821a0c7 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Fri, 4 Apr 2025 10:58:08 -0700 Subject: [PATCH 11/32] Recurrence validator tests --- .../_recurrence_validator.py | 13 +- tests/time_window_filter/recurrence_util.py | 48 +++++ .../test_recurrence_validator.py | 173 ++++++++++++------ 3 files changed, 166 insertions(+), 68 deletions(-) create mode 100644 tests/time_window_filter/recurrence_util.py diff --git a/featuremanagement/_time_window_filter/_recurrence_validator.py b/featuremanagement/_time_window_filter/_recurrence_validator.py index 67bd2a4..da94197 100644 --- a/featuremanagement/_time_window_filter/_recurrence_validator.py +++ b/featuremanagement/_time_window_filter/_recurrence_validator.py @@ -29,7 +29,7 @@ def validate_settings(recurrence: Recurrence, start: datetime, end: datetime) -> """ if not recurrence: raise ValueError("Recurrence is required") - + _validate_start_end_parameter(start, end) _validate_recurrence_pattern(recurrence.pattern, start, end) _validate_recurrence_range(recurrence.range, start) @@ -67,7 +67,7 @@ def _validate_weekly_recurrence_pattern(pattern: RecurrencePattern, start: datet # Check whether "Start" is a valid first occurrence if start.weekday() not in pattern.days_of_week: - raise ValueError(NOT_MATCHED % "start") + raise ValueError(NOT_MATCHED % start.strftime("%A")) # Time window duration must be shorter than how frequently it occurs _validate_time_window_duration(pattern, start, end) @@ -116,10 +116,12 @@ def _is_duration_compliant_with_days_of_week(pattern: RecurrencePattern, start: sorted_days_of_week = _sort_days_of_week(days_of_week, first_day_of_week) # Loop the whole week to get the min gap between the two consecutive recurrences - prev_occurrence = None + prev_occurrence = first_date_of_week + timedelta( + days=_get_passed_week_days(sorted_days_of_week[0], first_day_of_week) + ) min_gap = timedelta(days=DAYS_PER_WEEK) - for day in sorted_days_of_week: + for day in sorted_days_of_week[1:]: date = first_date_of_week + timedelta(days=_get_passed_week_days(day, first_day_of_week)) if prev_occurrence is not None: current_gap = date - prev_occurrence @@ -132,9 +134,6 @@ def _is_duration_compliant_with_days_of_week(pattern: RecurrencePattern, start: days=DAYS_PER_WEEK + _get_passed_week_days(sorted_days_of_week[0], first_day_of_week) ) - if not prev_occurrence: - return False - current_gap = date - prev_occurrence min_gap = min(min_gap, current_gap) diff --git a/tests/time_window_filter/recurrence_util.py b/tests/time_window_filter/recurrence_util.py new file mode 100644 index 0000000..a83463d --- /dev/null +++ b/tests/time_window_filter/recurrence_util.py @@ -0,0 +1,48 @@ +from datetime import datetime, timedelta +from featuremanagement._time_window_filter._models import Recurrence + +DATE_FORMAT = "%a, %d %b %Y %H:%M:%S" + +START_STRING = "Mon, 31 Mar 2025 00:00:00" +START = datetime.strptime(START_STRING, DATE_FORMAT) +END_STRING = "Mon, 31 Mar 2025 23:59:59" +END = datetime.strptime(END_STRING, DATE_FORMAT) + + +def valid_daily_pattern(): + return { + "Type": "Daily", + "Interval": 1, + "DaysOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], + "FirstDayOfWeek": "Sunday", + } + + +def valid_no_end_range(): + return { + "Type": "NoEnd", + "EndDate": None, + "NumberOfOccurrences": 10, + } + + +def valid_daily_recurrence(): + return Recurrence( + { + "Pattern": valid_daily_pattern(), + "Range": valid_no_end_range(), + } + ) + + +def valid_daily_end_date_recurrence(): + return Recurrence( + { + "Pattern": valid_daily_pattern(), + "Range": { + "Type": "EndDate", + "EndDate": (START + timedelta(days=10)).strftime(DATE_FORMAT), + "NumberOfOccurrences": 10, + }, + } + ) diff --git a/tests/time_window_filter/test_recurrence_validator.py b/tests/time_window_filter/test_recurrence_validator.py index 6de60c9..96baaab 100644 --- a/tests/time_window_filter/test_recurrence_validator.py +++ b/tests/time_window_filter/test_recurrence_validator.py @@ -1,88 +1,139 @@ +from datetime import timedelta, datetime import pytest -from datetime import datetime, timedelta +from recurrence_util import valid_daily_recurrence, valid_daily_end_date_recurrence, valid_no_end_range, START, END from featuremanagement._time_window_filter._models import Recurrence from featuremanagement._time_window_filter._recurrence_validator import validate_settings -def valid_daily_pattern(): - return { - "Type": "Daily", - "Interval": 1, - "DaysOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], - "FirstDayOfWeek": "Sunday", - } - -def valid_daily_recurrence(): - return Recurrence( - { - "Pattern": valid_daily_pattern(), - "Range": {"Type": "NoEnd", "EndDate": None, "NumberOfOccurrences": 10}, - } - ) - -def valid_daily_end_date_recurrence(): - return Recurrence( - { - "Pattern": valid_daily_pattern(), - "Range": {"Type": "EndDate", "EndDate": datetime.now() + timedelta(days=10), "NumberOfOccurrences": 10}, - } - ) - -def valid_weekly_recurrence(): - return Recurrence( - { - "Pattern": { - "Type": "Weekly", - "Interval": 1, - "DaysOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], - "FirstDayOfWeek": "Monday", - }, - "Range": {"Type": "NoEnd", "EndDate": None, "NumberOfOccurrences": 10}, - } - ) - def test_validate_settings_valid_daily(): - start = datetime.now() - end = start + timedelta(days=1) - validate_settings(valid_daily_recurrence(), start, end) + validate_settings(valid_daily_recurrence(), START, END) + def test_validate_settings_valid_daily_end_date(): - start = datetime.now() - end = start + timedelta(days=1) - validate_settings(valid_daily_end_date_recurrence(), start, end) + validate_settings(valid_daily_end_date_recurrence(), START, END) + + +def test_validate_settings_valid_weekly_one_day(): + validate_settings( + Recurrence( + { + "Pattern": { + "Type": "Weekly", + "Interval": 1, + "DaysOfWeek": ["Monday"], + "FirstDayOfWeek": "Monday", + }, + "Range": valid_no_end_range(), + } + ), + START, + END, + ) def test_validate_settings_valid_weekly(): - start = datetime.now() - end = start + timedelta(days=1) - validate_settings(valid_weekly_recurrence(), start, end) + validate_settings( + Recurrence( + { + "Pattern": { + "Type": "Weekly", + "Interval": 1, + "DaysOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], + "FirstDayOfWeek": "Monday", + }, + "Range": valid_no_end_range(), + } + ), + START, + END, + ) + def test_validate_settings_duration_exceeds_ten_years(): - start = datetime.now() - end = start + timedelta(days=3651) + end = START + timedelta(days=3651) with pytest.raises(ValueError, match="Time window duration exceeds ten years: end"): - validate_settings(valid_daily_recurrence(), start, end) + validate_settings(valid_daily_recurrence(), START, end) + def test_validate_settings_invalid_start_end(): - start = datetime.now() + timedelta(days=1) - end = datetime.now() + start = START + timedelta(days=2) with pytest.raises(ValueError, match="The filter start date Start needs to before the End date."): - validate_settings(valid_daily_recurrence(), start, end) + validate_settings(valid_daily_recurrence(), start, END) + def test_validate_settings_end_date_in_past(): - start = datetime.now() - end = datetime.now() - timedelta(days=1) + end = START - timedelta(days=1) with pytest.raises(ValueError, match="The filter start date Start needs to before the End date."): - validate_settings(valid_daily_recurrence(), start, end) + validate_settings(valid_daily_recurrence(), START, end) + def test_validate_settings_missing_recurrence(): - start = datetime.now() - end = start + timedelta(days=1) with pytest.raises(ValueError, match="Recurrence is required"): - validate_settings(None, start, end) + validate_settings(None, START, END) + def test_validate_settings_invalid_recurrence_pattern(): with pytest.raises(ValueError, match="Invalid value: InvalidType"): - Recurrence({"Pattern": {"Type": "InvalidType"}, "Range": {"Type": "NoEnd"}}) - + Recurrence({"Pattern": {"Type": "InvalidType"}, "Range": {"Type": "NoEnd"}}) + + +def test_validate_settings_weekly_recurrence_invalid_start_day(): + with pytest.raises(ValueError, match="Start day does not match any day of the week: Monday"): + validate_settings( + Recurrence( + { + "Pattern": { + "Type": "Weekly", + "Interval": 1, + "DaysOfWeek": ["Tuesday", "Wednesday", "Thursday", "Friday"], + "FirstDayOfWeek": "Monday", + }, + "Range": valid_no_end_range(), + } + ), + START, + END, + ) + + +def test_validate_settings_period_too_long(): + end = START + timedelta(days=7) + with pytest.raises(ValueError, match="Time window duration is out of range:"): + validate_settings(valid_daily_recurrence(), START, end) + + +def test_validate_settings_no_days_of_week(): + with pytest.raises(ValueError, match="Required parameter: Recurrence.Pattern.DaysOfWeek"): + validate_settings( + Recurrence( + { + "Pattern": { + "Type": "Weekly", + "Interval": 1, + "FirstDayOfWeek": "Monday", + }, + "Range": valid_no_end_range(), + } + ), + START, + END, + ) + + +def test_validate_settings_end_date_before_start(): + with pytest.raises(ValueError, match="The Recurrence.Range.EndDate should be after the Start"): + validate_settings(valid_daily_end_date_recurrence(), START + timedelta(days=11), END + timedelta(days=11)) + + +def test_is_duration_compliant_with_days_of_week_false(): + pattern = { + "Type": "Weekly", + "Interval": 1, + "DaysOfWeek": ["Monday", "Wednesday"], + "FirstDayOfWeek": "Monday", + } + start = datetime(2025, 4, 7, 9, 0, 0) # Monday + end = datetime(2025, 4, 10, 9, 0, 0) # Wednesday (48 hours duration) + with pytest.raises(ValueError, match="Recurrence.Pattern.DaysOfWeek"): + validate_settings(Recurrence({"Pattern": pattern, "Range": valid_no_end_range()}), start, end) From 8233ee52a1e12e54e0f8256d7345f9596e77414b Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Tue, 8 Apr 2025 10:41:20 -0700 Subject: [PATCH 12/32] Adding more tests --- .../_recurrence_evaluator.py | 2 - .../test_recurrence_evaluator.py | 274 ++++++++++++++++++ 2 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 tests/time_window_filter/test_recurrence_evaluator.py diff --git a/featuremanagement/_time_window_filter/_recurrence_evaluator.py b/featuremanagement/_time_window_filter/_recurrence_evaluator.py index b5f22f6..19ba3bc 100644 --- a/featuremanagement/_time_window_filter/_recurrence_evaluator.py +++ b/featuremanagement/_time_window_filter/_recurrence_evaluator.py @@ -49,8 +49,6 @@ def _get_previous_occurrence(recurrence: Recurrence, start: datetime, now: datet occurrence_info = _get_daily_previous_occurrence(recurrence, start, now) elif pattern_type == RecurrencePatternType.WEEKLY: occurrence_info = _get_weekly_previous_occurrence(recurrence, start, now) - else: - raise ValueError(f"Invalid recurrence pattern type: {pattern_type}") recurrence_range = recurrence.range range_type = recurrence_range.type diff --git a/tests/time_window_filter/test_recurrence_evaluator.py b/tests/time_window_filter/test_recurrence_evaluator.py new file mode 100644 index 0000000..2afcdd3 --- /dev/null +++ b/tests/time_window_filter/test_recurrence_evaluator.py @@ -0,0 +1,274 @@ +import pytest +from datetime import datetime, timedelta +from featuremanagement._time_window_filter._recurrence_evaluator import is_match +from featuremanagement._time_window_filter._models import TimeWindowFilterSettings, Recurrence + + +def test_is_match_within_time_window(): + start = datetime(2025, 4, 7, 9, 0, 0) + end = datetime(2025, 4, 7, 17, 0, 0) + now = datetime(2025, 4, 7, 10, 0, 0) + + recurrence = Recurrence( + { + "Pattern": {"Type": "Daily", "Interval": 1}, + "Range": {"Type": "NoEnd"}, + } + ) + + settings = TimeWindowFilterSettings(start=start, end=end, recurrence=recurrence) + + assert is_match(settings, now) is True + + +def test_is_match_outside_time_window(): + start = datetime(2025, 4, 7, 9, 0, 0) + end = datetime(2025, 4, 7, 17, 0, 0) + now = datetime(2025, 4, 7, 18, 0, 0) + + recurrence = Recurrence( + { + "Pattern": {"Type": "Daily", "Interval": 1}, + "Range": {"Type": "NoEnd"}, + } + ) + + settings = TimeWindowFilterSettings(start=start, end=end, recurrence=recurrence) + + assert is_match(settings, now) is False + + +def test_is_match_no_previous_occurrence(): + start = datetime(2025, 4, 7, 9, 0, 0) + end = datetime(2025, 4, 7, 17, 0, 0) + now = datetime(2025, 4, 6, 10, 0, 0) # Before the start time + + recurrence = Recurrence( + { + "Pattern": {"Type": "Daily", "Interval": 1}, + "Range": {"Type": "NoEnd"}, + } + ) + + settings = TimeWindowFilterSettings(start=start, end=end, recurrence=recurrence) + + assert is_match(settings, now) is False + + +def test_is_match_no_recurrence(): + start = datetime(2025, 4, 7, 9, 0, 0) + end = datetime(2025, 4, 7, 17, 0, 0) + now = datetime(2025, 4, 7, 10, 0, 0) + + settings = TimeWindowFilterSettings(start=start, end=end, recurrence=None) + + with pytest.raises(ValueError, match="Required parameter: Recurrence"): + is_match(settings, now) + + +def test_is_match_missing_start(): + end = datetime(2025, 4, 7, 17, 0, 0) + now = datetime(2025, 4, 7, 10, 0, 0) + + recurrence = Recurrence( + { + "Pattern": {"Type": "Daily", "Interval": 1}, + "Range": {"Type": "NoEnd"}, + } + ) + + settings = TimeWindowFilterSettings(start=None, end=end, recurrence=recurrence) + + with pytest.raises(ValueError, match="Required parameter: Start or End"): + is_match(settings, now) + + +def test_is_match_missing_end(): + start = datetime(2025, 4, 7, 9, 0, 0) + now = datetime(2025, 4, 7, 10, 0, 0) + + recurrence = Recurrence( + { + "Pattern": {"Type": "Daily", "Interval": 1}, + "Range": {"Type": "NoEnd"}, + } + ) + + settings = TimeWindowFilterSettings(start=start, end=None, recurrence=recurrence) + + with pytest.raises(ValueError, match="Required parameter: Start or End"): + is_match(settings, now) + + +def test_is_match_weekly_recurrence(): + start = datetime(2025, 4, 7, 9, 0, 0) # Monday + end = datetime(2025, 4, 7, 17, 0, 0) # Monday + now = datetime(2025, 4, 14, 10, 0, 0) # Next Monday + + recurrence = Recurrence( + { + "Pattern": {"Type": "Weekly", "Interval": 1, "DaysOfWeek": ["Monday"], "FirstDayOfWeek": "Monday"}, + "Range": {"Type": "NoEnd"}, + } + ) + + settings = TimeWindowFilterSettings(start=start, end=end, recurrence=recurrence) + + assert is_match(settings, now) is True + + +def test_is_match_end_date_has_passed(): + start = datetime(2025, 4, 7, 9, 0, 0) + end = datetime(2025, 4, 7, 17, 0, 0) + now = datetime(2025, 4, 9, 10, 0, 0) # After the end date + + recurrence = Recurrence( + { + "Pattern": {"Type": "Daily", "Interval": 1}, + "Range": {"Type": "EndDate", "EndDate": "Tue, 8 Apr 2025 10:00:00"}, + } + ) + + settings = TimeWindowFilterSettings(start=start, end=end, recurrence=recurrence) + + assert is_match(settings, now) is False + + +def test_is_match_numbered_recurrence(): + start = datetime(2025, 4, 7, 9, 0, 0) + end = datetime(2025, 4, 7, 17, 0, 0) + now = datetime(2025, 4, 8, 10, 0, 0) + + recurrence = Recurrence( + { + "Pattern": {"Type": "Daily", "Interval": 1}, + "Range": {"Type": "Numbered", "NumberOfOccurrences": 2}, + } + ) + + settings = TimeWindowFilterSettings(start=start, end=end, recurrence=recurrence) + + assert is_match(settings, now) is True + now = datetime(2025, 4, 15, 10, 0, 0) + assert is_match(settings, now) is False + + +def test_is_match_weekly_recurrence_with_occurrences_single_day(): + start = datetime(2025, 4, 7, 9, 0, 0) # Monday + end = datetime(2025, 4, 7, 17, 0, 0) # Monday + + recurrence = Recurrence( + { + "Pattern": { + "Type": "Weekly", + "Interval": 2, + "DaysOfWeek": ["Monday"], + "FirstDayOfWeek": "Monday", + }, + "Range": {"Type": "Numbered", "NumberOfOccurrences": 2}, + } + ) + + settings = TimeWindowFilterSettings(start=start, end=end, recurrence=recurrence) + + # First occurrence should match + assert is_match(settings, datetime(2025, 4, 7, 10, 0, 0)) is True + + # Second week occurrence shouldn't match + assert is_match(settings, datetime(2025, 4, 14, 10, 0, 0)) is False + + # Third week occurrence should match + assert is_match(settings, datetime(2025, 4, 21, 10, 0, 0)) is True + + # Fourth week occurrence shouldn't match + assert is_match(settings, datetime(2025, 4, 28, 10, 0, 0)) is False + + # Fifth week occurrence shouldn't match, passed the range + assert is_match(settings, datetime(2025, 5, 5, 10, 0, 0)) is False + +def test_is_match_weekly_recurrence_with_occurrences_multi_day(): + start = datetime(2025, 4, 7, 9, 0, 0) # Monday + end = datetime(2025, 4, 7, 17, 0, 0) # Monday + + recurrence = Recurrence( + { + "Pattern": { + "Type": "Weekly", + "Interval": 2, + "DaysOfWeek": ["Monday", "Tuesday"], + "FirstDayOfWeek": "Monday", + }, + "Range": {"Type": "Numbered", "NumberOfOccurrences": 4}, + } + ) + + settings = TimeWindowFilterSettings(start=start, end=end, recurrence=recurrence) + + # First occurrence should match + assert is_match(settings, datetime(2025, 4, 7, 10, 0, 0)) is True + assert is_match(settings, datetime(2025, 4, 8, 10, 0, 0)) is True + + # Second week occurrence shouldn't match + assert is_match(settings, datetime(2025, 4, 14, 10, 0, 0)) is False + assert is_match(settings, datetime(2025, 4, 15, 10, 0, 0)) is False + + assert is_match(settings, datetime(2025, 4, 7, 8, 0, 0)) is False + # Third week occurrence should match + assert is_match(settings, datetime(2025, 4, 21, 10, 0, 0)) is True + assert is_match(settings, datetime(2025, 4, 22, 10, 0, 0)) is True + + # Fourth week occurrence shouldn't match + assert is_match(settings, datetime(2025, 4, 28, 10, 0, 0)) is False + assert is_match(settings, datetime(2025, 4, 29, 10, 0, 0)) is False + + # Fifth week occurrence shouldn't match + assert is_match(settings, datetime(2025, 5, 5, 10, 0, 0)) is False + assert is_match(settings, datetime(2025, 5, 6, 10, 0, 0)) is False + + +def test_weekly_recurrence_start_after_min_offset(): + start = datetime(2025, 4, 9, 9, 0, 0) # Monday + end = datetime(2025, 4, 9, 17, 0, 0) # Monday + now = datetime(2025, 4, 12, 10, 0, 0) # Saturday + + recurrence = Recurrence( + { + "Pattern": { + "Type": "Weekly", + "Interval": 1, + "DaysOfWeek": ["Monday", "Wednesday"], + "FirstDayOfWeek": "Monday", + }, + "Range": {"Type": "NoEnd"}, + } + ) + + settings = TimeWindowFilterSettings(start=start, end=end, recurrence=recurrence) + + # Verify that the main method is_match correctly handles the scenario + assert is_match(settings, now) is False + assert is_match(settings, start) is True + + +def test_weekly_recurrence_now_before_min_offset(): + start = datetime(2025, 4, 9, 9, 0, 0) # Monday + end = datetime(2025, 4, 9, 17, 0, 0) # Monday + now = datetime(2025, 4, 16, 8, 0, 0) + + recurrence = Recurrence( + { + "Pattern": { + "Type": "Weekly", + "Interval": 1, + "DaysOfWeek": ["Wednesday", "Friday"], + "FirstDayOfWeek": "Monday", + }, + "Range": {"Type": "NoEnd"}, + } + ) + + settings = TimeWindowFilterSettings(start=start, end=end, recurrence=recurrence) + + # Verify that the main method is_match correctly handles the scenario + assert is_match(settings, now) is False + From 3686d7af7b283b6435c7e78e7458478f62d9deee Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Tue, 8 Apr 2025 10:41:40 -0700 Subject: [PATCH 13/32] Fixing bug where first day of the week had to be part of recurrence --- .../_time_window_filter/_recurrence_validator.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/featuremanagement/_time_window_filter/_recurrence_validator.py b/featuremanagement/_time_window_filter/_recurrence_validator.py index da94197..387489b 100644 --- a/featuremanagement/_time_window_filter/_recurrence_validator.py +++ b/featuremanagement/_time_window_filter/_recurrence_validator.py @@ -154,4 +154,11 @@ def _get_passed_week_days(current_day: int, first_day_of_week: int) -> int: def _sort_days_of_week(days_of_week: List[int], first_day_of_week: int) -> List[int]: sorted_days = sorted(days_of_week) - return sorted_days[sorted_days.index(first_day_of_week) :] + sorted_days[: sorted_days.index(first_day_of_week)] + if first_day_of_week in sorted_days: + return sorted_days[sorted_days.index(first_day_of_week) :] + sorted_days[: sorted_days.index(first_day_of_week)] + next_closet_day = first_day_of_week + for i in range(len(sorted_days)): + if sorted_days[i] > first_day_of_week: + next_closet_day = sorted_days[i] + break + return sorted_days[sorted_days.index(next_closet_day) :] + sorted_days[: sorted_days.index(next_closet_day)] From de6912ce56230162ad219591aeff3e33ba656191 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Tue, 8 Apr 2025 10:46:41 -0700 Subject: [PATCH 14/32] formatting --- .../_time_window_filter/_recurrence_evaluator.py | 2 ++ .../_time_window_filter/_recurrence_validator.py | 6 +++--- tests/time_window_filter/test_recurrence_evaluator.py | 11 ++++++++--- tests/time_window_filter/test_recurrence_validator.py | 5 +++++ 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/featuremanagement/_time_window_filter/_recurrence_evaluator.py b/featuremanagement/_time_window_filter/_recurrence_evaluator.py index 19ba3bc..9034b89 100644 --- a/featuremanagement/_time_window_filter/_recurrence_evaluator.py +++ b/featuremanagement/_time_window_filter/_recurrence_evaluator.py @@ -49,6 +49,8 @@ def _get_previous_occurrence(recurrence: Recurrence, start: datetime, now: datet occurrence_info = _get_daily_previous_occurrence(recurrence, start, now) elif pattern_type == RecurrencePatternType.WEEKLY: occurrence_info = _get_weekly_previous_occurrence(recurrence, start, now) + else: + raise ValueError(f"Unsupported recurrence pattern type: {pattern_type}") recurrence_range = recurrence.range range_type = recurrence_range.type diff --git a/featuremanagement/_time_window_filter/_recurrence_validator.py b/featuremanagement/_time_window_filter/_recurrence_validator.py index 387489b..d7530ea 100644 --- a/featuremanagement/_time_window_filter/_recurrence_validator.py +++ b/featuremanagement/_time_window_filter/_recurrence_validator.py @@ -157,8 +157,8 @@ def _sort_days_of_week(days_of_week: List[int], first_day_of_week: int) -> List[ if first_day_of_week in sorted_days: return sorted_days[sorted_days.index(first_day_of_week) :] + sorted_days[: sorted_days.index(first_day_of_week)] next_closet_day = first_day_of_week - for i in range(len(sorted_days)): - if sorted_days[i] > first_day_of_week: - next_closet_day = sorted_days[i] + for day in sorted_days: + if day > first_day_of_week: + next_closet_day = day break return sorted_days[sorted_days.index(next_closet_day) :] + sorted_days[: sorted_days.index(next_closet_day)] diff --git a/tests/time_window_filter/test_recurrence_evaluator.py b/tests/time_window_filter/test_recurrence_evaluator.py index 2afcdd3..b7955be 100644 --- a/tests/time_window_filter/test_recurrence_evaluator.py +++ b/tests/time_window_filter/test_recurrence_evaluator.py @@ -1,5 +1,10 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- +from datetime import datetime import pytest -from datetime import datetime, timedelta from featuremanagement._time_window_filter._recurrence_evaluator import is_match from featuremanagement._time_window_filter._models import TimeWindowFilterSettings, Recurrence @@ -186,6 +191,7 @@ def test_is_match_weekly_recurrence_with_occurrences_single_day(): # Fifth week occurrence shouldn't match, passed the range assert is_match(settings, datetime(2025, 5, 5, 10, 0, 0)) is False + def test_is_match_weekly_recurrence_with_occurrences_multi_day(): start = datetime(2025, 4, 7, 9, 0, 0) # Monday end = datetime(2025, 4, 7, 17, 0, 0) # Monday @@ -253,7 +259,7 @@ def test_weekly_recurrence_start_after_min_offset(): def test_weekly_recurrence_now_before_min_offset(): start = datetime(2025, 4, 9, 9, 0, 0) # Monday end = datetime(2025, 4, 9, 17, 0, 0) # Monday - now = datetime(2025, 4, 16, 8, 0, 0) + now = datetime(2025, 4, 16, 8, 0, 0) recurrence = Recurrence( { @@ -271,4 +277,3 @@ def test_weekly_recurrence_now_before_min_offset(): # Verify that the main method is_match correctly handles the scenario assert is_match(settings, now) is False - diff --git a/tests/time_window_filter/test_recurrence_validator.py b/tests/time_window_filter/test_recurrence_validator.py index 96baaab..14aa27b 100644 --- a/tests/time_window_filter/test_recurrence_validator.py +++ b/tests/time_window_filter/test_recurrence_validator.py @@ -1,3 +1,8 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- from datetime import timedelta, datetime import pytest from recurrence_util import valid_daily_recurrence, valid_daily_end_date_recurrence, valid_no_end_range, START, END From 040a1d671eac4a5d113dbda93435062df9293995 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Tue, 8 Apr 2025 10:48:39 -0700 Subject: [PATCH 15/32] Update test_recurrence_validator.py --- tests/time_window_filter/test_recurrence_validator.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/time_window_filter/test_recurrence_validator.py b/tests/time_window_filter/test_recurrence_validator.py index 14aa27b..3b0cecb 100644 --- a/tests/time_window_filter/test_recurrence_validator.py +++ b/tests/time_window_filter/test_recurrence_validator.py @@ -5,7 +5,13 @@ # ------------------------------------------------------------------------- from datetime import timedelta, datetime import pytest -from recurrence_util import valid_daily_recurrence, valid_daily_end_date_recurrence, valid_no_end_range, START, END +from time_window_filter.recurrence_util import ( + valid_daily_recurrence, + valid_daily_end_date_recurrence, + valid_no_end_range, + START, + END, +) from featuremanagement._time_window_filter._models import Recurrence from featuremanagement._time_window_filter._recurrence_validator import validate_settings From fbd17090298d646069e323bc8e3da61615cdf883 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Wed, 16 Apr 2025 09:37:36 -0700 Subject: [PATCH 16/32] review comments --- featuremanagement/_time_window_filter/_models.py | 3 +-- .../_time_window_filter/_recurrence_validator.py | 6 +++--- featuremanagement/_time_window_filter/conversiontest.py | 0 tests/time_window_filter/test_recurrence_validator.py | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) delete mode 100644 featuremanagement/_time_window_filter/conversiontest.py diff --git a/featuremanagement/_time_window_filter/_models.py b/featuremanagement/_time_window_filter/_models.py index 0459ec0..fbcb219 100644 --- a/featuremanagement/_time_window_filter/_models.py +++ b/featuremanagement/_time_window_filter/_models.py @@ -76,11 +76,10 @@ def __init__(self, pattern_data: Dict[str, Any]): self.interval = pattern_data.get("Interval", 1) if self.interval <= 0: raise ValueError("The interval must be greater than 0.") - # Days of the week are represented as a list of strings of there names. + # Days of the week are represented as a list of strings of their names. days_of_week_str = pattern_data.get("DaysOfWeek", []) # Days of the week are represented as a list of integers from 0 to 6. - # 0 = Sunday, 1 = Monday, ..., 6 = Saturday self.days_of_week = [] for day in days_of_week_str: self.days_of_week.append(self.days.index(day)) diff --git a/featuremanagement/_time_window_filter/_recurrence_validator.py b/featuremanagement/_time_window_filter/_recurrence_validator.py index d7530ea..2212d5b 100644 --- a/featuremanagement/_time_window_filter/_recurrence_validator.py +++ b/featuremanagement/_time_window_filter/_recurrence_validator.py @@ -111,7 +111,7 @@ def _is_duration_compliant_with_days_of_week(pattern: RecurrencePattern, start: # Get the date of first day of the week today = datetime.now() first_day_of_week = pattern.first_day_of_week - offset = _get_passed_week_days(today.weekday(), first_day_of_week) + offset = _get_passed_week_days((today.weekday() + 1) % 7, first_day_of_week) first_date_of_week = today - timedelta(days=offset) sorted_days_of_week = _sort_days_of_week(days_of_week, first_day_of_week) @@ -144,8 +144,8 @@ def _is_duration_compliant_with_days_of_week(pattern: RecurrencePattern, start: def _get_passed_week_days(current_day: int, first_day_of_week: int) -> int: """ Get the number of days passed since the first day of the week. - :param int current_day: The current day of the week, where Monday == 0 ... Sunday == 6. - :param int first_day_of_week: The first day of the week (0-6), where Monday == 0 ... Sunday == 6. + :param int current_day: The current day of the week, where Sunday == 0 ... Saturday == 6. + :param int first_day_of_week: The first day of the week (0-6), where Sunday == 0 ... Saturday == 6. :return: The number of days passed since the first day of the week. :rtype: int """ diff --git a/featuremanagement/_time_window_filter/conversiontest.py b/featuremanagement/_time_window_filter/conversiontest.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/time_window_filter/test_recurrence_validator.py b/tests/time_window_filter/test_recurrence_validator.py index 3b0cecb..e2dee93 100644 --- a/tests/time_window_filter/test_recurrence_validator.py +++ b/tests/time_window_filter/test_recurrence_validator.py @@ -5,7 +5,7 @@ # ------------------------------------------------------------------------- from datetime import timedelta, datetime import pytest -from time_window_filter.recurrence_util import ( +from .recurrence_util import ( valid_daily_recurrence, valid_daily_end_date_recurrence, valid_no_end_range, From 5f0dc785cd8adcca86beec91f37060689c76059f Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Wed, 16 Apr 2025 10:24:48 -0700 Subject: [PATCH 17/32] fix tests --- tests/time_window_filter/recurrence_util.py | 48 ----------------- .../test_recurrence_validator.py | 52 ++++++++++++++++--- 2 files changed, 45 insertions(+), 55 deletions(-) delete mode 100644 tests/time_window_filter/recurrence_util.py diff --git a/tests/time_window_filter/recurrence_util.py b/tests/time_window_filter/recurrence_util.py deleted file mode 100644 index a83463d..0000000 --- a/tests/time_window_filter/recurrence_util.py +++ /dev/null @@ -1,48 +0,0 @@ -from datetime import datetime, timedelta -from featuremanagement._time_window_filter._models import Recurrence - -DATE_FORMAT = "%a, %d %b %Y %H:%M:%S" - -START_STRING = "Mon, 31 Mar 2025 00:00:00" -START = datetime.strptime(START_STRING, DATE_FORMAT) -END_STRING = "Mon, 31 Mar 2025 23:59:59" -END = datetime.strptime(END_STRING, DATE_FORMAT) - - -def valid_daily_pattern(): - return { - "Type": "Daily", - "Interval": 1, - "DaysOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], - "FirstDayOfWeek": "Sunday", - } - - -def valid_no_end_range(): - return { - "Type": "NoEnd", - "EndDate": None, - "NumberOfOccurrences": 10, - } - - -def valid_daily_recurrence(): - return Recurrence( - { - "Pattern": valid_daily_pattern(), - "Range": valid_no_end_range(), - } - ) - - -def valid_daily_end_date_recurrence(): - return Recurrence( - { - "Pattern": valid_daily_pattern(), - "Range": { - "Type": "EndDate", - "EndDate": (START + timedelta(days=10)).strftime(DATE_FORMAT), - "NumberOfOccurrences": 10, - }, - } - ) diff --git a/tests/time_window_filter/test_recurrence_validator.py b/tests/time_window_filter/test_recurrence_validator.py index e2dee93..cc172ce 100644 --- a/tests/time_window_filter/test_recurrence_validator.py +++ b/tests/time_window_filter/test_recurrence_validator.py @@ -5,16 +5,54 @@ # ------------------------------------------------------------------------- from datetime import timedelta, datetime import pytest -from .recurrence_util import ( - valid_daily_recurrence, - valid_daily_end_date_recurrence, - valid_no_end_range, - START, - END, -) from featuremanagement._time_window_filter._models import Recurrence from featuremanagement._time_window_filter._recurrence_validator import validate_settings +DATE_FORMAT = "%a, %d %b %Y %H:%M:%S" + +START_STRING = "Mon, 31 Mar 2025 00:00:00" +START = datetime.strptime(START_STRING, DATE_FORMAT) +END_STRING = "Mon, 31 Mar 2025 23:59:59" +END = datetime.strptime(END_STRING, DATE_FORMAT) + + +def valid_daily_pattern(): + return { + "Type": "Daily", + "Interval": 1, + "DaysOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], + "FirstDayOfWeek": "Sunday", + } + + +def valid_no_end_range(): + return { + "Type": "NoEnd", + "EndDate": None, + "NumberOfOccurrences": 10, + } + + +def valid_daily_recurrence(): + return Recurrence( + { + "Pattern": valid_daily_pattern(), + "Range": valid_no_end_range(), + } + ) + + +def valid_daily_end_date_recurrence(): + return Recurrence( + { + "Pattern": valid_daily_pattern(), + "Range": { + "Type": "EndDate", + "EndDate": (START + timedelta(days=10)).strftime(DATE_FORMAT), + "NumberOfOccurrences": 10, + }, + } + ) def test_validate_settings_valid_daily(): validate_settings(valid_daily_recurrence(), START, END) From dc1d00640ec518e45d5da8ca018cdb9ed7e46a46 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Wed, 16 Apr 2025 10:27:58 -0700 Subject: [PATCH 18/32] Update test_recurrence_validator.py --- tests/time_window_filter/test_recurrence_validator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/time_window_filter/test_recurrence_validator.py b/tests/time_window_filter/test_recurrence_validator.py index cc172ce..4afb167 100644 --- a/tests/time_window_filter/test_recurrence_validator.py +++ b/tests/time_window_filter/test_recurrence_validator.py @@ -54,6 +54,7 @@ def valid_daily_end_date_recurrence(): } ) + def test_validate_settings_valid_daily(): validate_settings(valid_daily_recurrence(), START, END) From c55375b740a1bb23e826b19f1a49485767c0ed57 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Fri, 18 Apr 2025 11:20:08 -0700 Subject: [PATCH 19/32] Create __init__.py --- tests/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/__init__.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 From 10ccf1227a6a75cb01de9f0badd0ebc90647660b Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Fri, 18 Apr 2025 15:30:44 -0700 Subject: [PATCH 20/32] review comments --- .../_time_window_filter/_recurrence_validator.py | 10 +++++----- tests/time_window_filter/test_recurrence_evaluator.py | 2 +- tests/time_window_filter/test_recurrence_validator.py | 7 ++++++- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/featuremanagement/_time_window_filter/_recurrence_validator.py b/featuremanagement/_time_window_filter/_recurrence_validator.py index 2212d5b..80eae2d 100644 --- a/featuremanagement/_time_window_filter/_recurrence_validator.py +++ b/featuremanagement/_time_window_filter/_recurrence_validator.py @@ -156,9 +156,9 @@ def _sort_days_of_week(days_of_week: List[int], first_day_of_week: int) -> List[ sorted_days = sorted(days_of_week) if first_day_of_week in sorted_days: return sorted_days[sorted_days.index(first_day_of_week) :] + sorted_days[: sorted_days.index(first_day_of_week)] - next_closet_day = first_day_of_week - for day in sorted_days: - if day > first_day_of_week: - next_closet_day = day + next_closest_day = first_day_of_week + for i in range(7): + if (first_day_of_week + i) % 7 in sorted_days: + next_closest_day = (first_day_of_week + i) % 7 break - return sorted_days[sorted_days.index(next_closet_day) :] + sorted_days[: sorted_days.index(next_closet_day)] + return sorted_days[sorted_days.index(next_closest_day) :] + sorted_days[: sorted_days.index(next_closest_day)] diff --git a/tests/time_window_filter/test_recurrence_evaluator.py b/tests/time_window_filter/test_recurrence_evaluator.py index b7955be..47c8213 100644 --- a/tests/time_window_filter/test_recurrence_evaluator.py +++ b/tests/time_window_filter/test_recurrence_evaluator.py @@ -12,7 +12,7 @@ def test_is_match_within_time_window(): start = datetime(2025, 4, 7, 9, 0, 0) end = datetime(2025, 4, 7, 17, 0, 0) - now = datetime(2025, 4, 7, 10, 0, 0) + now = datetime(2025, 4, 8, 10, 0, 0) recurrence = Recurrence( { diff --git a/tests/time_window_filter/test_recurrence_validator.py b/tests/time_window_filter/test_recurrence_validator.py index 4afb167..3271ccb 100644 --- a/tests/time_window_filter/test_recurrence_validator.py +++ b/tests/time_window_filter/test_recurrence_validator.py @@ -6,7 +6,7 @@ from datetime import timedelta, datetime import pytest from featuremanagement._time_window_filter._models import Recurrence -from featuremanagement._time_window_filter._recurrence_validator import validate_settings +from featuremanagement._time_window_filter._recurrence_validator import validate_settings, _sort_days_of_week DATE_FORMAT = "%a, %d %b %Y %H:%M:%S" @@ -187,3 +187,8 @@ def test_is_duration_compliant_with_days_of_week_false(): end = datetime(2025, 4, 10, 9, 0, 0) # Wednesday (48 hours duration) with pytest.raises(ValueError, match="Recurrence.Pattern.DaysOfWeek"): validate_settings(Recurrence({"Pattern": pattern, "Range": valid_no_end_range()}), start, end) + +def test_sort_days_of_week(): + days_of_week = [0, 3, 5] # Monday, Thursday, Saturday + sorted_days = _sort_days_of_week(days_of_week, 6) + assert sorted_days == [0, 3, 5] \ No newline at end of file From 14692e697ff753c618387698c31a3d6f5640b380 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Fri, 18 Apr 2025 16:59:11 -0700 Subject: [PATCH 21/32] Added more tests and checks --- .../_time_window_filter/_models.py | 9 +- .../test_recurrence_validator.py | 22 ++++- .../test_time_window_filter_models.py | 87 +++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 tests/time_window_filter/test_time_window_filter_models.py diff --git a/featuremanagement/_time_window_filter/_models.py b/featuremanagement/_time_window_filter/_models.py index fbcb219..f64aff1 100644 --- a/featuremanagement/_time_window_filter/_models.py +++ b/featuremanagement/_time_window_filter/_models.py @@ -82,7 +82,11 @@ def __init__(self, pattern_data: Dict[str, Any]): # Days of the week are represented as a list of integers from 0 to 6. self.days_of_week = [] for day in days_of_week_str: + if day not in self.days: + raise ValueError(f"Invalid value for DaysOfWeek: {day}") self.days_of_week.append(self.days.index(day)) + if pattern_data.get("FirstDayOfWeek") and pattern_data.get("FirstDayOfWeek") not in self.days: + raise ValueError(f"Invalid value for FirstDayOfWeek: {pattern_data.get('FirstDayOfWeek')}") self.first_day_of_week = self.days.index(pattern_data.get("FirstDayOfWeek", "Sunday")) @@ -98,7 +102,10 @@ def __init__(self, range_data: Dict[str, Any]): self.type = RecurrenceRangeType.from_str(range_data.get("Type", "NoEnd")) if range_data.get("EndDate") and isinstance(range_data.get("EndDate"), str): end_date_str = range_data.get("EndDate", "") - self.end_date = parsedate_to_datetime(end_date_str) if end_date_str else None + try: + self.end_date = parsedate_to_datetime(end_date_str) if end_date_str else None + except ValueError: + raise ValueError(f"Invalid value for EndDate: {end_date_str}") self.num_of_occurrences = range_data.get("NumberOfOccurrences", math.pow(2, 63) - 1) if self.num_of_occurrences < 0: raise ValueError("The number of occurrences must be greater than or equal to 0.") diff --git a/tests/time_window_filter/test_recurrence_validator.py b/tests/time_window_filter/test_recurrence_validator.py index 3271ccb..07ed674 100644 --- a/tests/time_window_filter/test_recurrence_validator.py +++ b/tests/time_window_filter/test_recurrence_validator.py @@ -191,4 +191,24 @@ def test_is_duration_compliant_with_days_of_week_false(): def test_sort_days_of_week(): days_of_week = [0, 3, 5] # Monday, Thursday, Saturday sorted_days = _sort_days_of_week(days_of_week, 6) - assert sorted_days == [0, 3, 5] \ No newline at end of file + assert sorted_days == [0, 3, 5] + + days_of_week = [5, 0, 3] # Saturday, Monday, Thursday + sorted_days = _sort_days_of_week(days_of_week, 6) + assert sorted_days == [0, 3, 5] + + days_of_week = [0, 1, 2, 3, 4, 5, 6] # All days of the week + sorted_days = _sort_days_of_week(days_of_week, 6) + assert sorted_days == [6, 0, 1, 2, 3, 4, 5] + + days_of_week = [6, 5, 4, 3, 2, 1, 0] # All days of the week in reverse order + sorted_days = _sort_days_of_week(days_of_week, 6) + assert sorted_days == [6, 0, 1, 2, 3, 4, 5] + + days_of_week = [0, 2, 4, 6] # Monday, Wednesday, Friday, Sunday + sorted_days = _sort_days_of_week(days_of_week, 2) + assert sorted_days == [2, 4, 6, 0] + + days_of_week = [1] # Tuesday + sorted_days = _sort_days_of_week(days_of_week, 0) + assert sorted_days == [1] \ No newline at end of file diff --git a/tests/time_window_filter/test_time_window_filter_models.py b/tests/time_window_filter/test_time_window_filter_models.py new file mode 100644 index 0000000..54dfa17 --- /dev/null +++ b/tests/time_window_filter/test_time_window_filter_models.py @@ -0,0 +1,87 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- +from featuremanagement._time_window_filter._models import RecurrencePatternType, RecurrenceRangeType, RecurrencePattern, RecurrenceRange, Recurrence +from datetime import datetime +import math + +def test_recurrence_pattern_type(): + assert RecurrencePatternType.from_str("Daily") == RecurrencePatternType.DAILY + assert RecurrencePatternType.from_str("Weekly") == RecurrencePatternType.WEEKLY + try: + RecurrencePatternType.from_str("Invalid") + except ValueError as e: + assert str(e) == "Invalid value: Invalid" + +def test_recurrence_range_type(): + assert RecurrenceRangeType.from_str("NoEnd") == RecurrenceRangeType.NO_END + assert RecurrenceRangeType.from_str("EndDate") == RecurrenceRangeType.END_DATE + assert RecurrenceRangeType.from_str("Numbered") == RecurrenceRangeType.NUMBERED + try: + RecurrenceRangeType.from_str("Invalid") + except ValueError as e: + assert str(e) == "Invalid value: Invalid" + +def test_recurrence_pattern(): + pattern = RecurrencePattern({"Type":"Daily", "Interval":1, "DaysOfWeek":["Monday", "Tuesday"]}) + assert pattern.type == RecurrencePatternType.DAILY + assert pattern.interval == 1 + assert pattern.days_of_week == [0, 1] + assert pattern.first_day_of_week == 6 + + pattern = RecurrencePattern({"Type":"Daily", "Interval":1, "DaysOfWeek":["Monday", "Tuesday"], "FirstDayOfWeek":"Monday"}) + assert pattern.type == RecurrencePatternType.DAILY + assert pattern.interval == 1 + assert pattern.days_of_week == [0, 1] + assert pattern.first_day_of_week == 0 + + try: + pattern = RecurrencePattern({"Type":"Daily", "Interval":1, "DaysOfWeek":["Monday", "Tuesday"], "FirstDayOfWeek":"Thor's day"}) + except ValueError as e: + assert str(e) == "Invalid value for FirstDayOfWeek: Thor's day" + + pattern = RecurrencePattern({"Type":"Weekly", "Interval":2, "DaysOfWeek":["Wednesday"]}) + assert pattern.type == RecurrencePatternType.WEEKLY + assert pattern.interval == 2 + assert pattern.days_of_week == [2] + assert pattern.first_day_of_week == 6 + + try: + pattern = RecurrencePattern({"Type":"Daily", "Interval":0, "DaysOfWeek":["Monday", "Tuesday"]}) + except ValueError as e: + assert str(e) == "The interval must be greater than 0." + + try: + pattern = RecurrencePattern({"Type":"Daily", "Interval":1, "DaysOfWeek":["Monday", "Thor's day"]}) + except ValueError as e: + assert str(e) == "Invalid value for DaysOfWeek: Thor's day" + +def test_recurrence_range(): + max_occurrences = math.pow(2, 63) - 1 + + range = RecurrenceRange({"Type":"NoEnd"}) + assert range.type == RecurrenceRangeType.NO_END + assert range.end_date is None + assert range.num_of_occurrences == max_occurrences + + range = RecurrenceRange({"Type":"EndDate", "EndDate":"Mon, 31 Mar 2025 00:00:00"}) + assert range.type == RecurrenceRangeType.END_DATE + assert range.end_date == datetime(2025, 3, 31, 0, 0, 0) + assert range.num_of_occurrences == max_occurrences + + range = RecurrenceRange({"Type":"Numbered", "NumberOfOccurrences":10}) + assert range.type == RecurrenceRangeType.NUMBERED + assert range.end_date is None + assert range.num_of_occurrences == 10 + + try: + range = RecurrenceRange({"Type":"NoEnd", "NumberOfOccurrences":-1}) + except ValueError as e: + assert str(e) == "The number of occurrences must be greater than or equal to 0." + + try: + range = RecurrenceRange({"Type":"EndDate", "EndDate":"InvalidDate"}) + except ValueError as e: + assert str(e) == "Invalid value for EndDate: InvalidDate" \ No newline at end of file From 856c4c68c76f456c5cfd80120a801f61702ec752 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Fri, 18 Apr 2025 17:06:36 -0700 Subject: [PATCH 22/32] fix formatting --- .../_time_window_filter/_models.py | 4 +- .../test_recurrence_validator.py | 15 +++-- .../test_time_window_filter_models.py | 67 +++++++++++++++---- 3 files changed, 64 insertions(+), 22 deletions(-) diff --git a/featuremanagement/_time_window_filter/_models.py b/featuremanagement/_time_window_filter/_models.py index f64aff1..6cf206a 100644 --- a/featuremanagement/_time_window_filter/_models.py +++ b/featuremanagement/_time_window_filter/_models.py @@ -104,8 +104,8 @@ def __init__(self, range_data: Dict[str, Any]): end_date_str = range_data.get("EndDate", "") try: self.end_date = parsedate_to_datetime(end_date_str) if end_date_str else None - except ValueError: - raise ValueError(f"Invalid value for EndDate: {end_date_str}") + except ValueError as e: + raise ValueError(f'Invalid value for EndDate: {end_date_str}') from e self.num_of_occurrences = range_data.get("NumberOfOccurrences", math.pow(2, 63) - 1) if self.num_of_occurrences < 0: raise ValueError("The number of occurrences must be greater than or equal to 0.") diff --git a/tests/time_window_filter/test_recurrence_validator.py b/tests/time_window_filter/test_recurrence_validator.py index 07ed674..c81a271 100644 --- a/tests/time_window_filter/test_recurrence_validator.py +++ b/tests/time_window_filter/test_recurrence_validator.py @@ -188,27 +188,28 @@ def test_is_duration_compliant_with_days_of_week_false(): with pytest.raises(ValueError, match="Recurrence.Pattern.DaysOfWeek"): validate_settings(Recurrence({"Pattern": pattern, "Range": valid_no_end_range()}), start, end) + def test_sort_days_of_week(): - days_of_week = [0, 3, 5] # Monday, Thursday, Saturday + days_of_week = [0, 3, 5] # Monday, Thursday, Saturday sorted_days = _sort_days_of_week(days_of_week, 6) assert sorted_days == [0, 3, 5] - days_of_week = [5, 0, 3] # Saturday, Monday, Thursday + days_of_week = [5, 0, 3] # Saturday, Monday, Thursday sorted_days = _sort_days_of_week(days_of_week, 6) assert sorted_days == [0, 3, 5] - days_of_week = [0, 1, 2, 3, 4, 5, 6] # All days of the week + days_of_week = [0, 1, 2, 3, 4, 5, 6] # All days of the week sorted_days = _sort_days_of_week(days_of_week, 6) assert sorted_days == [6, 0, 1, 2, 3, 4, 5] - days_of_week = [6, 5, 4, 3, 2, 1, 0] # All days of the week in reverse order + days_of_week = [6, 5, 4, 3, 2, 1, 0] # All days of the week in reverse order sorted_days = _sort_days_of_week(days_of_week, 6) assert sorted_days == [6, 0, 1, 2, 3, 4, 5] - days_of_week = [0, 2, 4, 6] # Monday, Wednesday, Friday, Sunday + days_of_week = [0, 2, 4, 6] # Monday, Wednesday, Friday, Sunday sorted_days = _sort_days_of_week(days_of_week, 2) assert sorted_days == [2, 4, 6, 0] - days_of_week = [1] # Tuesday + days_of_week = [1] # Tuesday sorted_days = _sort_days_of_week(days_of_week, 0) - assert sorted_days == [1] \ No newline at end of file + assert sorted_days == [1] diff --git a/tests/time_window_filter/test_time_window_filter_models.py b/tests/time_window_filter/test_time_window_filter_models.py index 54dfa17..bf67f06 100644 --- a/tests/time_window_filter/test_time_window_filter_models.py +++ b/tests/time_window_filter/test_time_window_filter_models.py @@ -3,10 +3,17 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # ------------------------------------------------------------------------- -from featuremanagement._time_window_filter._models import RecurrencePatternType, RecurrenceRangeType, RecurrencePattern, RecurrenceRange, Recurrence +from featuremanagement._time_window_filter._models import ( + RecurrencePatternType, + RecurrenceRangeType, + RecurrencePattern, + RecurrenceRange, + Recurrence, +) from datetime import datetime import math + def test_recurrence_pattern_type(): assert RecurrencePatternType.from_str("Daily") == RecurrencePatternType.DAILY assert RecurrencePatternType.from_str("Weekly") == RecurrencePatternType.WEEKLY @@ -15,6 +22,7 @@ def test_recurrence_pattern_type(): except ValueError as e: assert str(e) == "Invalid value: Invalid" + def test_recurrence_range_type(): assert RecurrenceRangeType.from_str("NoEnd") == RecurrenceRangeType.NO_END assert RecurrenceRangeType.from_str("EndDate") == RecurrenceRangeType.END_DATE @@ -24,64 +32,97 @@ def test_recurrence_range_type(): except ValueError as e: assert str(e) == "Invalid value: Invalid" + def test_recurrence_pattern(): - pattern = RecurrencePattern({"Type":"Daily", "Interval":1, "DaysOfWeek":["Monday", "Tuesday"]}) + pattern = RecurrencePattern({"Type": "Daily", "Interval": 1, "DaysOfWeek": ["Monday", "Tuesday"]}) assert pattern.type == RecurrencePatternType.DAILY assert pattern.interval == 1 assert pattern.days_of_week == [0, 1] assert pattern.first_day_of_week == 6 - pattern = RecurrencePattern({"Type":"Daily", "Interval":1, "DaysOfWeek":["Monday", "Tuesday"], "FirstDayOfWeek":"Monday"}) + pattern = RecurrencePattern( + {"Type": "Daily", "Interval": 1, "DaysOfWeek": ["Monday", "Tuesday"], "FirstDayOfWeek": "Monday"} + ) assert pattern.type == RecurrencePatternType.DAILY assert pattern.interval == 1 assert pattern.days_of_week == [0, 1] assert pattern.first_day_of_week == 0 try: - pattern = RecurrencePattern({"Type":"Daily", "Interval":1, "DaysOfWeek":["Monday", "Tuesday"], "FirstDayOfWeek":"Thor's day"}) + pattern = RecurrencePattern( + {"Type": "Daily", "Interval": 1, "DaysOfWeek": ["Monday", "Tuesday"], "FirstDayOfWeek": "Thor's day"} + ) except ValueError as e: assert str(e) == "Invalid value for FirstDayOfWeek: Thor's day" - pattern = RecurrencePattern({"Type":"Weekly", "Interval":2, "DaysOfWeek":["Wednesday"]}) + pattern = RecurrencePattern({"Type": "Weekly", "Interval": 2, "DaysOfWeek": ["Wednesday"]}) assert pattern.type == RecurrencePatternType.WEEKLY assert pattern.interval == 2 assert pattern.days_of_week == [2] assert pattern.first_day_of_week == 6 try: - pattern = RecurrencePattern({"Type":"Daily", "Interval":0, "DaysOfWeek":["Monday", "Tuesday"]}) + pattern = RecurrencePattern({"Type": "Daily", "Interval": 0, "DaysOfWeek": ["Monday", "Tuesday"]}) except ValueError as e: assert str(e) == "The interval must be greater than 0." try: - pattern = RecurrencePattern({"Type":"Daily", "Interval":1, "DaysOfWeek":["Monday", "Thor's day"]}) + pattern = RecurrencePattern({"Type": "Daily", "Interval": 1, "DaysOfWeek": ["Monday", "Thor's day"]}) except ValueError as e: assert str(e) == "Invalid value for DaysOfWeek: Thor's day" + def test_recurrence_range(): max_occurrences = math.pow(2, 63) - 1 - range = RecurrenceRange({"Type":"NoEnd"}) + range = RecurrenceRange({"Type": "NoEnd"}) assert range.type == RecurrenceRangeType.NO_END assert range.end_date is None assert range.num_of_occurrences == max_occurrences - range = RecurrenceRange({"Type":"EndDate", "EndDate":"Mon, 31 Mar 2025 00:00:00"}) + range = RecurrenceRange({"Type": "EndDate", "EndDate": "Mon, 31 Mar 2025 00:00:00"}) assert range.type == RecurrenceRangeType.END_DATE assert range.end_date == datetime(2025, 3, 31, 0, 0, 0) assert range.num_of_occurrences == max_occurrences - range = RecurrenceRange({"Type":"Numbered", "NumberOfOccurrences":10}) + range = RecurrenceRange({"Type": "Numbered", "NumberOfOccurrences": 10}) assert range.type == RecurrenceRangeType.NUMBERED assert range.end_date is None assert range.num_of_occurrences == 10 try: - range = RecurrenceRange({"Type":"NoEnd", "NumberOfOccurrences":-1}) + range = RecurrenceRange({"Type": "NoEnd", "NumberOfOccurrences": -1}) except ValueError as e: assert str(e) == "The number of occurrences must be greater than or equal to 0." try: - range = RecurrenceRange({"Type":"EndDate", "EndDate":"InvalidDate"}) + range = RecurrenceRange({"Type": "EndDate", "EndDate": "InvalidDate"}) + except ValueError as e: + assert str(e) == "Invalid value for EndDate: InvalidDate" + + +def test_recurrence(): + recurrence = Recurrence( + {"Pattern": {"Type": "Daily", "Interval": 1, "DaysOfWeek": ["Monday"]}, "Range": {"Type": "NoEnd"}} + ) + assert recurrence.pattern.type == RecurrencePatternType.DAILY + assert recurrence.range.type == RecurrenceRangeType.NO_END + + try: + recurrence = Recurrence({"Pattern": {"Type": "Invalid"}, "Range": {"Type": "NoEnd"}}) + except ValueError as e: + assert str(e) == "Invalid value: Invalid" + + try: + recurrence = Recurrence( + {"Pattern": {"Type": "Daily", "Interval": 0, "DaysOfWeek": ["Monday"]}, "Range": {"Type": "NoEnd"}} + ) + except ValueError as e: + assert str(e) == "The interval must be greater than 0." + + try: + recurrence = Recurrence( + {"Pattern": {"Type": "Daily", "Interval": 1, "DaysOfWeek": ["Invalid"]}, "Range": {"Type": "NoEnd"}} + ) except ValueError as e: - assert str(e) == "Invalid value for EndDate: InvalidDate" \ No newline at end of file + assert str(e) == "Invalid value for DaysOfWeek: Invalid" From c48141f26b5981d0c1083ecfae1d855c836aca0e Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Fri, 18 Apr 2025 17:09:13 -0700 Subject: [PATCH 23/32] Update .gitignore --- .gitignore | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index cab6cf5..abafdcd 100644 --- a/.gitignore +++ b/.gitignore @@ -411,5 +411,5 @@ docs/_static docs/_templates docs/doctrees docs/html -package-lock.json -package.json +**/package-lock.json +**/package.json From 1d50962edd9a6b5ca89126a8d67752786128fcda Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Fri, 18 Apr 2025 17:09:52 -0700 Subject: [PATCH 24/32] Update .gitignore --- .gitignore | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index abafdcd..cab6cf5 100644 --- a/.gitignore +++ b/.gitignore @@ -411,5 +411,5 @@ docs/_static docs/_templates docs/doctrees docs/html -**/package-lock.json -**/package.json +package-lock.json +package.json From 9657883ccf8225e963c7fdb02419b533dd2b6eab Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Fri, 18 Apr 2025 17:11:18 -0700 Subject: [PATCH 25/32] Update _models.py --- featuremanagement/_time_window_filter/_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/featuremanagement/_time_window_filter/_models.py b/featuremanagement/_time_window_filter/_models.py index 6cf206a..19a3484 100644 --- a/featuremanagement/_time_window_filter/_models.py +++ b/featuremanagement/_time_window_filter/_models.py @@ -105,7 +105,7 @@ def __init__(self, range_data: Dict[str, Any]): try: self.end_date = parsedate_to_datetime(end_date_str) if end_date_str else None except ValueError as e: - raise ValueError(f'Invalid value for EndDate: {end_date_str}') from e + raise ValueError(f"Invalid value for EndDate: {end_date_str}") from e self.num_of_occurrences = range_data.get("NumberOfOccurrences", math.pow(2, 63) - 1) if self.num_of_occurrences < 0: raise ValueError("The number of occurrences must be greater than or equal to 0.") From 3b3958516c1f2f7e24810a5df1804c00ea041a70 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Fri, 18 Apr 2025 17:30:26 -0700 Subject: [PATCH 26/32] Update _models.py --- featuremanagement/_time_window_filter/_models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/featuremanagement/_time_window_filter/_models.py b/featuremanagement/_time_window_filter/_models.py index 19a3484..709a4a3 100644 --- a/featuremanagement/_time_window_filter/_models.py +++ b/featuremanagement/_time_window_filter/_models.py @@ -106,6 +106,9 @@ def __init__(self, range_data: Dict[str, Any]): self.end_date = parsedate_to_datetime(end_date_str) if end_date_str else None except ValueError as e: raise ValueError(f"Invalid value for EndDate: {end_date_str}") from e + except TypeError as e: + # Python 3.9 and earlier throw TypeError if the string is not in RFC 2822 format. + raise ValueError(f"Invalid value for EndDate: {end_date_str}") from e self.num_of_occurrences = range_data.get("NumberOfOccurrences", math.pow(2, 63) - 1) if self.num_of_occurrences < 0: raise ValueError("The number of occurrences must be greater than or equal to 0.") From f429fc5fed1ab1700d6f08ae17358d76aef40254 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Mon, 21 Apr 2025 10:00:07 -0700 Subject: [PATCH 27/32] num_of_occurrences > 0 --- featuremanagement/_time_window_filter/_models.py | 4 ++-- tests/time_window_filter/test_time_window_filter_models.py | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/featuremanagement/_time_window_filter/_models.py b/featuremanagement/_time_window_filter/_models.py index 709a4a3..c4bed8f 100644 --- a/featuremanagement/_time_window_filter/_models.py +++ b/featuremanagement/_time_window_filter/_models.py @@ -110,8 +110,8 @@ def __init__(self, range_data: Dict[str, Any]): # Python 3.9 and earlier throw TypeError if the string is not in RFC 2822 format. raise ValueError(f"Invalid value for EndDate: {end_date_str}") from e self.num_of_occurrences = range_data.get("NumberOfOccurrences", math.pow(2, 63) - 1) - if self.num_of_occurrences < 0: - raise ValueError("The number of occurrences must be greater than or equal to 0.") + if self.num_of_occurrences <= 0: + raise ValueError("The number of occurrences must be greater than 0.") class Recurrence: # pylint: disable=too-few-public-methods diff --git a/tests/time_window_filter/test_time_window_filter_models.py b/tests/time_window_filter/test_time_window_filter_models.py index bf67f06..41b9bf3 100644 --- a/tests/time_window_filter/test_time_window_filter_models.py +++ b/tests/time_window_filter/test_time_window_filter_models.py @@ -93,7 +93,12 @@ def test_recurrence_range(): try: range = RecurrenceRange({"Type": "NoEnd", "NumberOfOccurrences": -1}) except ValueError as e: - assert str(e) == "The number of occurrences must be greater than or equal to 0." + assert str(e) == "The number of occurrences must be greater than 0." + + try: + range = RecurrenceRange({"Type": "NoEnd", "NumberOfOccurrences": 0}) + except ValueError as e: + assert str(e) == "The number of occurrences must be greater than 0." try: range = RecurrenceRange({"Type": "EndDate", "EndDate": "InvalidDate"}) From 706588a5bce2a74a3eb0d69a4ffdc770eb2670bc Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Mon, 21 Apr 2025 12:45:50 -0700 Subject: [PATCH 28/32] review comments --- featuremanagement/_defaultfilters.py | 4 ---- featuremanagement/_time_window_filter/_models.py | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/featuremanagement/_defaultfilters.py b/featuremanagement/_defaultfilters.py index 1b78d4e..75b8b7a 100644 --- a/featuremanagement/_defaultfilters.py +++ b/featuremanagement/_defaultfilters.py @@ -84,10 +84,6 @@ def evaluate(self, context: Mapping[Any, Any], **kwargs: Any) -> bool: start_time: Optional[datetime] = parsedate_to_datetime(start) if start else None end_time: Optional[datetime] = parsedate_to_datetime(end) if end else None - if not start and not end: - logging.warning("%s: At least one of Start or End is required.", TimeWindowFilter.__name__) - return False - if (start_time is None or start_time <= current_time) and (end_time is None or current_time < end_time): return True diff --git a/featuremanagement/_time_window_filter/_models.py b/featuremanagement/_time_window_filter/_models.py index c4bed8f..13d6ce2 100644 --- a/featuremanagement/_time_window_filter/_models.py +++ b/featuremanagement/_time_window_filter/_models.py @@ -109,7 +109,7 @@ def __init__(self, range_data: Dict[str, Any]): except TypeError as e: # Python 3.9 and earlier throw TypeError if the string is not in RFC 2822 format. raise ValueError(f"Invalid value for EndDate: {end_date_str}") from e - self.num_of_occurrences = range_data.get("NumberOfOccurrences", math.pow(2, 63) - 1) + self.num_of_occurrences = range_data.get("NumberOfOccurrences", 2 ** 63 - 1) if self.num_of_occurrences <= 0: raise ValueError("The number of occurrences must be greater than 0.") From c76178a15e6ce31fd4803ee4e538520b0c45a753 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Mon, 21 Apr 2025 13:31:52 -0700 Subject: [PATCH 29/32] added tests fixed max int --- .../_time_window_filter/_models.py | 7 +- .../test_recurrence_validator.py | 106 ++++++++++++++++++ .../test_time_window_filter_models.py | 2 +- 3 files changed, 111 insertions(+), 4 deletions(-) diff --git a/featuremanagement/_time_window_filter/_models.py b/featuremanagement/_time_window_filter/_models.py index 13d6ce2..ff3574b 100644 --- a/featuremanagement/_time_window_filter/_models.py +++ b/featuremanagement/_time_window_filter/_models.py @@ -3,7 +3,6 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # ------------------------------------------------------------------------- -import math from enum import Enum from typing import Dict, Any, Optional, List from datetime import datetime @@ -80,10 +79,12 @@ def __init__(self, pattern_data: Dict[str, Any]): days_of_week_str = pattern_data.get("DaysOfWeek", []) # Days of the week are represented as a list of integers from 0 to 6. - self.days_of_week = [] + self.days_of_week: List[int] = [] for day in days_of_week_str: if day not in self.days: raise ValueError(f"Invalid value for DaysOfWeek: {day}") + if self.days.index(day) in self.days_of_week: + raise ValueError(f"Duplicate day of the week found: {day}") self.days_of_week.append(self.days.index(day)) if pattern_data.get("FirstDayOfWeek") and pattern_data.get("FirstDayOfWeek") not in self.days: raise ValueError(f"Invalid value for FirstDayOfWeek: {pattern_data.get('FirstDayOfWeek')}") @@ -109,7 +110,7 @@ def __init__(self, range_data: Dict[str, Any]): except TypeError as e: # Python 3.9 and earlier throw TypeError if the string is not in RFC 2822 format. raise ValueError(f"Invalid value for EndDate: {end_date_str}") from e - self.num_of_occurrences = range_data.get("NumberOfOccurrences", 2 ** 63 - 1) + self.num_of_occurrences = range_data.get("NumberOfOccurrences", 2**63 - 1) if self.num_of_occurrences <= 0: raise ValueError("The number of occurrences must be greater than 0.") diff --git a/tests/time_window_filter/test_recurrence_validator.py b/tests/time_window_filter/test_recurrence_validator.py index c81a271..3d22f4f 100644 --- a/tests/time_window_filter/test_recurrence_validator.py +++ b/tests/time_window_filter/test_recurrence_validator.py @@ -213,3 +213,109 @@ def test_sort_days_of_week(): days_of_week = [1] # Tuesday sorted_days = _sort_days_of_week(days_of_week, 0) assert sorted_days == [1] + + +def test_validate_settings_invalid_days_of_week(): + with pytest.raises(ValueError, match="Invalid value for DaysOfWeek: Thor's Day"): + validate_settings( + Recurrence( + { + "Pattern": { + "Type": "Weekly", + "Interval": 1, + "DaysOfWeek": ["Thor's Day"], + "FirstDayOfWeek": "Monday", + }, + "Range": valid_no_end_range(), + } + ), + START, + END, + ) + with pytest.raises(ValueError, match="Required parameter: Recurrence.Pattern.DaysOfWeek"): + validate_settings( + Recurrence( + { + "Pattern": { + "Type": "Weekly", + "Interval": 1, + "DaysOfWeek": [], + "FirstDayOfWeek": "Monday", + }, + "Range": valid_no_end_range(), + } + ), + START, + END, + ) + + +def test_validate_settings_invalid_days_of_week_duplicate(): + with pytest.raises(ValueError, match="Duplicate day of the week found: Monday"): + validate_settings( + Recurrence( + { + "Pattern": { + "Type": "Weekly", + "Interval": 1, + "DaysOfWeek": ["Monday", "Monday"], + "FirstDayOfWeek": "Monday", + }, + "Range": valid_no_end_range(), + } + ), + START, + END, + ) + + +def test_validate_settings_invalid_end_date_format(): + with pytest.raises(ValueError, match="Invalid value for EndDate: invalid-date-format"): + validate_settings( + Recurrence( + { + "Pattern": valid_daily_pattern(), + "Range": { + "Type": "EndDate", + "EndDate": "invalid-date-format", + "NumberOfOccurrences": 10, + }, + } + ), + START, + END, + ) + + +def test_validate_settings_boundary_condition_ten_years(): + end = START + timedelta(days=3650) # Exactly 10 years + recurrence = Recurrence( + { + "Pattern": { + "Type": "Daily", + "Interval": 3651, # Interval greater than the time window length + "DaysOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], + "FirstDayOfWeek": "Sunday", + }, + "Range": valid_no_end_range(), + } + ) + validate_settings(recurrence, START, end) + recurrence = Recurrence( + { + "Pattern": { + "Type": "Daily", + "Interval": 3652, # Interval greater than the time window length + "DaysOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], + "FirstDayOfWeek": "Sunday", + }, + "Range": valid_no_end_range(), + } + ) + with pytest.raises(ValueError, match="Time window duration exceeds ten years: end"): + validate_settings(recurrence, START, START + timedelta(days=3652)) + + +def test_validate_settings_boundary_condition_interval(): + end = START + timedelta(days=1) # Exactly matches the interval duration for daily recurrence + validate_settings(valid_daily_recurrence(), START, end) diff --git a/tests/time_window_filter/test_time_window_filter_models.py b/tests/time_window_filter/test_time_window_filter_models.py index 41b9bf3..651d43a 100644 --- a/tests/time_window_filter/test_time_window_filter_models.py +++ b/tests/time_window_filter/test_time_window_filter_models.py @@ -73,7 +73,7 @@ def test_recurrence_pattern(): def test_recurrence_range(): - max_occurrences = math.pow(2, 63) - 1 + max_occurrences = 2**63 - 1 range = RecurrenceRange({"Type": "NoEnd"}) assert range.type == RecurrenceRangeType.NO_END From 07e9ea4a8b3480767ea07c7714271fe212a9af4f Mon Sep 17 00:00:00 2001 From: Matthew Metcalf Date: Wed, 23 Apr 2025 09:03:21 -0700 Subject: [PATCH 30/32] Update tests/time_window_filter/test_recurrence_evaluator.py Co-authored-by: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> --- tests/time_window_filter/test_recurrence_evaluator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/time_window_filter/test_recurrence_evaluator.py b/tests/time_window_filter/test_recurrence_evaluator.py index 47c8213..bcd313c 100644 --- a/tests/time_window_filter/test_recurrence_evaluator.py +++ b/tests/time_window_filter/test_recurrence_evaluator.py @@ -259,7 +259,7 @@ def test_weekly_recurrence_start_after_min_offset(): def test_weekly_recurrence_now_before_min_offset(): start = datetime(2025, 4, 9, 9, 0, 0) # Monday end = datetime(2025, 4, 9, 17, 0, 0) # Monday - now = datetime(2025, 4, 16, 8, 0, 0) + now = datetime(2025, 4, 16, 8, 0, 0) # Saturday recurrence = Recurrence( { From d15668223d7e26806d141330df39dbec475817a3 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Wed, 23 Apr 2025 09:09:36 -0700 Subject: [PATCH 31/32] Added comments to test --- .../test_recurrence_evaluator.py | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/tests/time_window_filter/test_recurrence_evaluator.py b/tests/time_window_filter/test_recurrence_evaluator.py index 47c8213..254f909 100644 --- a/tests/time_window_filter/test_recurrence_evaluator.py +++ b/tests/time_window_filter/test_recurrence_evaluator.py @@ -210,26 +210,28 @@ def test_is_match_weekly_recurrence_with_occurrences_multi_day(): settings = TimeWindowFilterSettings(start=start, end=end, recurrence=recurrence) + # Before the start time, should not match + assert is_match(settings, datetime(2025, 4, 7, 8, 0, 0)) is False # Monday + # First occurrence should match - assert is_match(settings, datetime(2025, 4, 7, 10, 0, 0)) is True - assert is_match(settings, datetime(2025, 4, 8, 10, 0, 0)) is True + assert is_match(settings, datetime(2025, 4, 7, 10, 0, 0)) is True # Monday + assert is_match(settings, datetime(2025, 4, 8, 10, 0, 0)) is True # Tuesday # Second week occurrence shouldn't match - assert is_match(settings, datetime(2025, 4, 14, 10, 0, 0)) is False - assert is_match(settings, datetime(2025, 4, 15, 10, 0, 0)) is False + assert is_match(settings, datetime(2025, 4, 14, 10, 0, 0)) is False # Monday + assert is_match(settings, datetime(2025, 4, 15, 10, 0, 0)) is False # Tuesday - assert is_match(settings, datetime(2025, 4, 7, 8, 0, 0)) is False # Third week occurrence should match - assert is_match(settings, datetime(2025, 4, 21, 10, 0, 0)) is True - assert is_match(settings, datetime(2025, 4, 22, 10, 0, 0)) is True + assert is_match(settings, datetime(2025, 4, 21, 10, 0, 0)) is True # Monday + assert is_match(settings, datetime(2025, 4, 22, 10, 0, 0)) is True # Tuesday # Fourth week occurrence shouldn't match - assert is_match(settings, datetime(2025, 4, 28, 10, 0, 0)) is False - assert is_match(settings, datetime(2025, 4, 29, 10, 0, 0)) is False + assert is_match(settings, datetime(2025, 4, 28, 10, 0, 0)) is False # Monday + assert is_match(settings, datetime(2025, 4, 29, 10, 0, 0)) is False # Tuesday # Fifth week occurrence shouldn't match - assert is_match(settings, datetime(2025, 5, 5, 10, 0, 0)) is False - assert is_match(settings, datetime(2025, 5, 6, 10, 0, 0)) is False + assert is_match(settings, datetime(2025, 5, 5, 10, 0, 0)) is False # Monday + assert is_match(settings, datetime(2025, 5, 6, 10, 0, 0)) is False # Tuesday def test_weekly_recurrence_start_after_min_offset(): From 50d270dec1575266e011e174f13249ab70d32ae6 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Wed, 23 Apr 2025 09:10:06 -0700 Subject: [PATCH 32/32] Update test_recurrence_evaluator.py --- tests/time_window_filter/test_recurrence_evaluator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/time_window_filter/test_recurrence_evaluator.py b/tests/time_window_filter/test_recurrence_evaluator.py index f892ae6..8dabf49 100644 --- a/tests/time_window_filter/test_recurrence_evaluator.py +++ b/tests/time_window_filter/test_recurrence_evaluator.py @@ -261,7 +261,7 @@ def test_weekly_recurrence_start_after_min_offset(): def test_weekly_recurrence_now_before_min_offset(): start = datetime(2025, 4, 9, 9, 0, 0) # Monday end = datetime(2025, 4, 9, 17, 0, 0) # Monday - now = datetime(2025, 4, 16, 8, 0, 0) # Saturday + now = datetime(2025, 4, 16, 8, 0, 0) # Saturday recurrence = Recurrence( {