FeatureManagement 2.0.0b3__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. featuremanagement-2.1.0/CHANGELOG.md +3 -0
  2. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0/FeatureManagement.egg-info}/PKG-INFO +6 -2
  3. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/FeatureManagement.egg-info/SOURCES.txt +10 -0
  4. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/FeatureManagement.egg-info/top_level.txt +1 -0
  5. {featuremanagement-2.0.0b3/FeatureManagement.egg-info → featuremanagement-2.1.0}/PKG-INFO +6 -2
  6. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_defaultfilters.py +34 -6
  7. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_featuremanager.py +23 -3
  8. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_featuremanagerbase.py +12 -4
  9. featuremanagement-2.1.0/featuremanagement/_time_window_filter/__init__.py +9 -0
  10. featuremanagement-2.1.0/featuremanagement/_time_window_filter/_models.py +149 -0
  11. featuremanagement-2.1.0/featuremanagement/_time_window_filter/_recurrence_evaluator.py +125 -0
  12. featuremanagement-2.1.0/featuremanagement/_time_window_filter/_recurrence_validator.py +164 -0
  13. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_version.py +1 -1
  14. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/aio/_featuremanager.py +27 -3
  15. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/azuremonitor/__init__.py +2 -1
  16. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/azuremonitor/_send_telemetry.py +61 -9
  17. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/pyproject.toml +1 -1
  18. featuremanagement-2.1.0/samples/feature_variant_sample_with_targeting_accessor.py +36 -0
  19. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/samples/formatted_feature_flags.json +1 -0
  20. featuremanagement-2.1.0/samples/quarty_sample.py +60 -0
  21. featuremanagement-2.1.0/tests/__init__.py +0 -0
  22. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/tests/test_default_feature_flags.py +49 -0
  23. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/tests/test_default_feature_flags_async.py +50 -0
  24. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/tests/test_send_telemetry_appinsights.py +51 -14
  25. featuremanagement-2.1.0/tests/time_window_filter/test_recurrence_evaluator.py +281 -0
  26. featuremanagement-2.1.0/tests/time_window_filter/test_recurrence_validator.py +321 -0
  27. featuremanagement-2.1.0/tests/time_window_filter/test_time_window_filter_models.py +133 -0
  28. featuremanagement-2.0.0b3/CHANGELOG.md +0 -32
  29. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/CODE_OF_CONDUCT.md +0 -0
  30. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/FeatureManagement.egg-info/dependency_links.txt +0 -0
  31. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/FeatureManagement.egg-info/not-zip-safe +0 -0
  32. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/FeatureManagement.egg-info/requires.txt +0 -0
  33. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/LICENSE +0 -0
  34. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/MANIFEST.in +0 -0
  35. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/README.md +0 -0
  36. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/SECURITY.md +0 -0
  37. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/SUPPORT.md +0 -0
  38. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/__init__.py +0 -0
  39. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_featurefilters.py +0 -0
  40. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_models/__init__.py +0 -0
  41. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_models/_allocation.py +0 -0
  42. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_models/_constants.py +0 -0
  43. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_models/_evaluation_event.py +0 -0
  44. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_models/_feature_conditions.py +0 -0
  45. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_models/_feature_flag.py +0 -0
  46. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_models/_targeting_context.py +0 -0
  47. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_models/_telemetry.py +0 -0
  48. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_models/_variant.py +0 -0
  49. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_models/_variant_assignment_reason.py +0 -0
  50. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_models/_variant_reference.py +0 -0
  51. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/aio/__init__.py +0 -0
  52. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/aio/_defaultfilters.py +0 -0
  53. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/aio/_featurefilters.py +0 -0
  54. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/py.typed +0 -0
  55. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/samples/feature_flag_sample.py +0 -0
  56. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/samples/feature_flag_with_azure_app_configuration_sample.py +0 -0
  57. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/samples/feature_variant_sample.py +0 -0
  58. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/samples/feature_variant_sample_with_telemetry.py +0 -0
  59. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/samples/random_filter.py +0 -0
  60. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/setup.cfg +0 -0
  61. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/setup.py +0 -0
  62. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/tests/test_feature_manager.py +0 -0
  63. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/tests/test_feature_manager_async.py +0 -0
  64. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/tests/test_feature_manager_refresh.py +0 -0
  65. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/tests/test_feature_variants.py +0 -0
  66. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/tests/test_feature_variants_async.py +0 -0
  67. {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/tests/validation_tests/test_json_validations.py +0 -0
@@ -0,0 +1,3 @@
1
+ # Release History
2
+
3
+ See [Release Notes](https://github.com/Azure/AppConfiguration/blob/main/releaseNotes/PythonFeatureManagement.md) for the full release history.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: FeatureManagement
3
- Version: 2.0.0b3
3
+ Version: 2.1.0
4
4
  Summary: A library for enabling/disabling features at runtime.
5
5
  Home-page: https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/appconfiguration/feature-management
6
6
  Author: Microsoft Corporation
@@ -43,6 +43,10 @@ Description-Content-Type: text/markdown
43
43
  License-File: LICENSE
44
44
  Provides-Extra: azuremonitor
45
45
  Requires-Dist: azure-monitor-events-extension<2.0.0; extra == "azuremonitor"
46
+ Dynamic: author
47
+ Dynamic: home-page
48
+ Dynamic: license-file
49
+ Dynamic: requires-python
46
50
 
47
51
  # Microsoft Feature Management for Python
48
52
 
@@ -31,6 +31,10 @@ featuremanagement/_models/_telemetry.py
31
31
  featuremanagement/_models/_variant.py
32
32
  featuremanagement/_models/_variant_assignment_reason.py
33
33
  featuremanagement/_models/_variant_reference.py
34
+ featuremanagement/_time_window_filter/__init__.py
35
+ featuremanagement/_time_window_filter/_models.py
36
+ featuremanagement/_time_window_filter/_recurrence_evaluator.py
37
+ featuremanagement/_time_window_filter/_recurrence_validator.py
34
38
  featuremanagement/aio/__init__.py
35
39
  featuremanagement/aio/_defaultfilters.py
36
40
  featuremanagement/aio/_featurefilters.py
@@ -40,9 +44,12 @@ featuremanagement/azuremonitor/_send_telemetry.py
40
44
  samples/feature_flag_sample.py
41
45
  samples/feature_flag_with_azure_app_configuration_sample.py
42
46
  samples/feature_variant_sample.py
47
+ samples/feature_variant_sample_with_targeting_accessor.py
43
48
  samples/feature_variant_sample_with_telemetry.py
44
49
  samples/formatted_feature_flags.json
50
+ samples/quarty_sample.py
45
51
  samples/random_filter.py
52
+ tests/__init__.py
46
53
  tests/test_default_feature_flags.py
47
54
  tests/test_default_feature_flags_async.py
48
55
  tests/test_feature_manager.py
@@ -51,4 +58,7 @@ tests/test_feature_manager_refresh.py
51
58
  tests/test_feature_variants.py
52
59
  tests/test_feature_variants_async.py
53
60
  tests/test_send_telemetry_appinsights.py
61
+ tests/time_window_filter/test_recurrence_evaluator.py
62
+ tests/time_window_filter/test_recurrence_validator.py
63
+ tests/time_window_filter/test_time_window_filter_models.py
54
64
  tests/validation_tests/test_json_validations.py
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: FeatureManagement
3
- Version: 2.0.0b3
3
+ Version: 2.1.0
4
4
  Summary: A library for enabling/disabling features at runtime.
5
5
  Home-page: https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/appconfiguration/feature-management
6
6
  Author: Microsoft Corporation
@@ -43,6 +43,10 @@ Description-Content-Type: text/markdown
43
43
  License-File: LICENSE
44
44
  Provides-Extra: azuremonitor
45
45
  Requires-Dist: azure-monitor-events-extension<2.0.0; extra == "azuremonitor"
46
+ Dynamic: author
47
+ Dynamic: home-page
48
+ Dynamic: license-file
49
+ Dynamic: requires-python
46
50
 
47
51
  # Microsoft Feature Management for Python
48
52
 
@@ -9,6 +9,7 @@ from datetime import datetime, timezone
9
9
  from email.utils import parsedate_to_datetime
10
10
  from typing import cast, List, Mapping, Optional, Dict, Any
11
11
  from ._featurefilters import FeatureFilter
12
+ from ._time_window_filter import Recurrence, is_match, TimeWindowFilterSettings
12
13
 
13
14
  FEATURE_FLAG_NAME_KEY = "feature_name"
14
15
  ROLLOUT_PERCENTAGE_KEY = "RolloutPercentage"
@@ -18,6 +19,15 @@ PARAMETERS_KEY = "parameters"
18
19
  # Time Window Constants
19
20
  START_KEY = "Start"
20
21
  END_KEY = "End"
22
+ TIME_WINDOW_FILTER_SETTING_RECURRENCE = "Recurrence"
23
+
24
+ # Time Window Exceptions
25
+ TIME_WINDOW_FILTER_INVALID = (
26
+ "{}: The {} feature filter is not valid for feature {}. It must specify either {}, {}, or both."
27
+ )
28
+ TIME_WINDOW_FILTER_INVALID_RECURRENCE = (
29
+ "{}: The {} feature filter is not valid for feature {}. It must specify both {} and {} when Recurrence is not None."
30
+ )
21
31
 
22
32
  # Targeting kwargs
23
33
  TARGETED_USER_KEY = "user"
@@ -31,6 +41,8 @@ EXCLUSION_KEY = "Exclusion"
31
41
  FEATURE_FILTER_NAME_KEY = "Name"
32
42
  IGNORE_CASE_KEY = "ignore_case"
33
43
 
44
+ logger = logging.getLogger(__name__)
45
+
34
46
 
35
47
  class TargetingException(Exception):
36
48
  """
@@ -52,19 +64,35 @@ class TimeWindowFilter(FeatureFilter):
52
64
  :return: True if the current time is within the time window.
53
65
  :rtype: bool
54
66
  """
55
- start = context.get(PARAMETERS_KEY, {}).get(START_KEY)
56
- end = context.get(PARAMETERS_KEY, {}).get(END_KEY)
67
+ start = context.get(PARAMETERS_KEY, {}).get(START_KEY, None)
68
+ end = context.get(PARAMETERS_KEY, {}).get(END_KEY, None)
69
+ recurrence_data = context.get(PARAMETERS_KEY, {}).get(TIME_WINDOW_FILTER_SETTING_RECURRENCE, None)
70
+ recurrence = None
57
71
 
58
72
  current_time = datetime.now(timezone.utc)
59
73
 
60
74
  if not start and not end:
61
- logging.warning("%s: At least one of Start or End is required.", TimeWindowFilter.__name__)
75
+ logger.warning(
76
+ TIME_WINDOW_FILTER_INVALID,
77
+ TimeWindowFilter.__name__,
78
+ context.get(FEATURE_FLAG_NAME_KEY),
79
+ START_KEY,
80
+ END_KEY,
81
+ )
62
82
  return False
63
83
 
64
- start_time = parsedate_to_datetime(start) if start else None
65
- end_time = parsedate_to_datetime(end) if end else None
84
+ start_time: Optional[datetime] = parsedate_to_datetime(start) if start else None
85
+ end_time: Optional[datetime] = parsedate_to_datetime(end) if end else None
86
+
87
+ if (start_time is None or start_time <= current_time) and (end_time is None or current_time < end_time):
88
+ return True
89
+
90
+ if recurrence_data:
91
+ recurrence = Recurrence(recurrence_data)
92
+ settings = TimeWindowFilterSettings(start_time, end_time, recurrence)
93
+ return is_match(settings, current_time)
66
94
 
67
- return (start_time is None or start_time <= current_time) and (end_time is None or current_time < end_time)
95
+ return False
68
96
 
69
97
 
70
98
  @FeatureFilter.alias("Microsoft.Targeting")
@@ -3,7 +3,8 @@
3
3
  # Licensed under the MIT License. See License.txt in the project root for
4
4
  # license information.
5
5
  # -------------------------------------------------------------------------
6
- from typing import cast, overload, Any, Optional, Dict, Mapping, List
6
+ import logging
7
+ from typing import cast, overload, Any, Optional, Dict, Mapping, List, Tuple
7
8
  from ._defaultfilters import TimeWindowFilter, TargetingFilter
8
9
  from ._featurefilters import FeatureFilter
9
10
  from ._models import EvaluationEvent, Variant, TargetingContext
@@ -14,6 +15,8 @@ from ._featuremanagerbase import (
14
15
  FEATURE_FILTER_NAME,
15
16
  )
16
17
 
18
+ logger = logging.getLogger(__name__)
19
+
17
20
 
18
21
  class FeatureManager(FeatureManagerBase):
19
22
  """
@@ -23,6 +26,8 @@ class FeatureManager(FeatureManagerBase):
23
26
  :keyword list[FeatureFilter] feature_filters: Custom filters to be used for evaluating feature flags.
24
27
  :keyword Callable[EvaluationEvent] on_feature_evaluated: Callback function to be called when a feature flag is
25
28
  evaluated.
29
+ :keyword Callable[[], TargetingContext] targeting_context_accessor: Callback function to get the current targeting
30
+ context if one isn't provided.
26
31
  """
27
32
 
28
33
  def __init__(self, configuration: Mapping[str, Any], **kwargs: Any):
@@ -56,7 +61,7 @@ class FeatureManager(FeatureManagerBase):
56
61
  :return: True if the feature flag is enabled for the given context.
57
62
  :rtype: bool
58
63
  """
59
- targeting_context = self._build_targeting_context(args)
64
+ targeting_context: TargetingContext = self._build_targeting_context(args)
60
65
 
61
66
  result = self._check_feature(feature_flag_id, targeting_context, **kwargs)
62
67
  if (
@@ -89,7 +94,7 @@ class FeatureManager(FeatureManagerBase):
89
94
  :return: Variant instance.
90
95
  :rtype: Variant
91
96
  """
92
- targeting_context = self._build_targeting_context(args)
97
+ targeting_context: TargetingContext = self._build_targeting_context(args)
93
98
 
94
99
  result = self._check_feature(feature_flag_id, targeting_context, **kwargs)
95
100
  if (
@@ -102,6 +107,21 @@ class FeatureManager(FeatureManagerBase):
102
107
  self._on_feature_evaluated(result)
103
108
  return result.variant
104
109
 
110
+ def _build_targeting_context(self, args: Tuple[Any]) -> TargetingContext:
111
+ targeting_context = super()._build_targeting_context(args)
112
+ if targeting_context:
113
+ return targeting_context
114
+ if not targeting_context and self._targeting_context_accessor and callable(self._targeting_context_accessor):
115
+ targeting_context = self._targeting_context_accessor()
116
+ if targeting_context and isinstance(targeting_context, TargetingContext):
117
+ return targeting_context
118
+ logger.warning(
119
+ "targeting_context_accessor did not return a TargetingContext. Received type %s.",
120
+ type(targeting_context),
121
+ )
122
+
123
+ return TargetingContext()
124
+
105
125
  def _check_feature_filters(
106
126
  self, evaluation_event: EvaluationEvent, targeting_context: TargetingContext, **kwargs: Any
107
127
  ) -> None:
@@ -6,7 +6,7 @@
6
6
  import hashlib
7
7
  import logging
8
8
  from abc import ABC
9
- from typing import List, Optional, Dict, Tuple, Any, Mapping
9
+ from typing import List, Optional, Dict, Tuple, Any, Mapping, Callable
10
10
  from ._models import FeatureFlag, Variant, VariantAssignmentReason, TargetingContext, EvaluationEvent, VariantReference
11
11
 
12
12
 
@@ -21,6 +21,9 @@ REQUIREMENT_TYPE_ANY = "Any"
21
21
  FEATURE_FILTER_PARAMETERS = "parameters"
22
22
 
23
23
 
24
+ logger = logging.getLogger(__name__)
25
+
26
+
24
27
  def _get_feature_flag(configuration: Mapping[str, Any], feature_flag_name: str) -> Optional[FeatureFlag]:
25
28
  """
26
29
  Gets the FeatureFlag json from the configuration, if it exists it gets converted to a FeatureFlag object.
@@ -77,6 +80,9 @@ class FeatureManagerBase(ABC):
77
80
  self._cache: Dict[str, Optional[FeatureFlag]] = {}
78
81
  self._copy = configuration.get(FEATURE_MANAGEMENT_KEY)
79
82
  self._on_feature_evaluated = kwargs.pop("on_feature_evaluated", None)
83
+ self._targeting_context_accessor: Optional[Callable[[], TargetingContext]] = kwargs.pop(
84
+ "targeting_context_accessor", None
85
+ )
80
86
 
81
87
  @staticmethod
82
88
  def _assign_default_disabled_variant(evaluation_event: EvaluationEvent) -> None:
@@ -218,7 +224,7 @@ class FeatureManagerBase(ABC):
218
224
  return Variant(variant_reference.name, variant_reference.configuration_value)
219
225
  return None
220
226
 
221
- def _build_targeting_context(self, args: Tuple[Any]) -> TargetingContext:
227
+ def _build_targeting_context(self, args: Tuple[Any]) -> Optional[TargetingContext]:
222
228
  """
223
229
  Builds a TargetingContext, either returns a provided context, takes the provided user_id to make a context, or
224
230
  returns an empty context.
@@ -229,10 +235,12 @@ class FeatureManagerBase(ABC):
229
235
  if len(args) == 1:
230
236
  arg = args[0]
231
237
  if isinstance(arg, str):
238
+ # If the user_id is provided, return a TargetingContext with the user_id
232
239
  return TargetingContext(user_id=arg, groups=[])
233
240
  if isinstance(arg, TargetingContext):
241
+ # If a TargetingContext is provided, return it
234
242
  return arg
235
- return TargetingContext()
243
+ return None
236
244
 
237
245
  def _assign_allocation(self, evaluation_event: EvaluationEvent, targeting_context: TargetingContext) -> None:
238
246
  feature_flag = evaluation_event.feature
@@ -271,7 +279,7 @@ class FeatureManagerBase(ABC):
271
279
 
272
280
  evaluation_event = EvaluationEvent(feature_flag)
273
281
  if not feature_flag:
274
- logging.warning("Feature flag %s not found", feature_flag_id)
282
+ logger.warning("Feature flag %s not found", feature_flag_id)
275
283
  # Unknown feature flags are disabled by default
276
284
  return evaluation_event, True
277
285
 
@@ -0,0 +1,9 @@
1
+ # ------------------------------------------------------------------------
2
+ # Copyright (c) Microsoft Corporation. All rights reserved.
3
+ # Licensed under the MIT License. See License.txt in the project root for
4
+ # license information.
5
+ # -------------------------------------------------------------------------
6
+ from ._recurrence_evaluator import is_match
7
+ from ._models import Recurrence, TimeWindowFilterSettings
8
+
9
+ __all__ = ["is_match", "Recurrence", "TimeWindowFilterSettings"]
@@ -0,0 +1,149 @@
1
+ # ------------------------------------------------------------------------
2
+ # Copyright (c) Microsoft Corporation. All rights reserved.
3
+ # Licensed under the MIT License. See License.txt in the project root for
4
+ # license information.
5
+ # -------------------------------------------------------------------------
6
+ from enum import Enum
7
+ from typing import Dict, Any, Optional, List
8
+ from datetime import datetime
9
+ from dataclasses import dataclass
10
+ from email.utils import parsedate_to_datetime
11
+
12
+
13
+ class RecurrencePatternType(str, Enum):
14
+ """
15
+ The recurrence pattern type.
16
+ """
17
+
18
+ DAILY = "Daily"
19
+ WEEKLY = "Weekly"
20
+
21
+ @staticmethod
22
+ def from_str(value: str) -> "RecurrencePatternType":
23
+ """
24
+ Get the RecurrencePatternType from the string value.
25
+
26
+ :param value: The string value.
27
+ :type value: str
28
+ :return: The RecurrencePatternType.
29
+ :rtype: RecurrencePatternType
30
+ """
31
+ if value == "Daily":
32
+ return RecurrencePatternType.DAILY
33
+ if value == "Weekly":
34
+ return RecurrencePatternType.WEEKLY
35
+ raise ValueError(f"Invalid value: {value}")
36
+
37
+
38
+ class RecurrenceRangeType(str, Enum):
39
+ """
40
+ The recurrence range type.
41
+ """
42
+
43
+ NO_END = "NoEnd"
44
+ END_DATE = "EndDate"
45
+ NUMBERED = "Numbered"
46
+
47
+ @staticmethod
48
+ def from_str(value: str) -> "RecurrenceRangeType":
49
+ """
50
+ Get the RecurrenceRangeType from the string value.
51
+
52
+ :param value: The string value.
53
+ :type value: str
54
+ :return: The RecurrenceRangeType.
55
+ :rtype: RecurrenceRangeType
56
+ """
57
+ if value == "NoEnd":
58
+ return RecurrenceRangeType.NO_END
59
+ if value == "EndDate":
60
+ return RecurrenceRangeType.END_DATE
61
+ if value == "Numbered":
62
+ return RecurrenceRangeType.NUMBERED
63
+ raise ValueError(f"Invalid value: {value}")
64
+
65
+
66
+ class RecurrencePattern: # pylint: disable=too-few-public-methods
67
+ """
68
+ The recurrence pattern settings.
69
+ """
70
+
71
+ days: List[str] = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
72
+
73
+ def __init__(self, pattern_data: Dict[str, Any]):
74
+ self.type = RecurrencePatternType.from_str(pattern_data.get("Type", "Daily"))
75
+ self.interval = pattern_data.get("Interval", 1)
76
+ if self.interval <= 0:
77
+ raise ValueError("The interval must be greater than 0.")
78
+ # Days of the week are represented as a list of strings of their names.
79
+ days_of_week_str = pattern_data.get("DaysOfWeek", [])
80
+
81
+ # Days of the week are represented as a list of integers from 0 to 6.
82
+ self.days_of_week: List[int] = []
83
+ for day in days_of_week_str:
84
+ if day not in self.days:
85
+ raise ValueError(f"Invalid value for DaysOfWeek: {day}")
86
+ if self.days.index(day) in self.days_of_week:
87
+ raise ValueError(f"Duplicate day of the week found: {day}")
88
+ self.days_of_week.append(self.days.index(day))
89
+ if pattern_data.get("FirstDayOfWeek") and pattern_data.get("FirstDayOfWeek") not in self.days:
90
+ raise ValueError(f"Invalid value for FirstDayOfWeek: {pattern_data.get('FirstDayOfWeek')}")
91
+ self.first_day_of_week = self.days.index(pattern_data.get("FirstDayOfWeek", "Sunday"))
92
+
93
+
94
+ class RecurrenceRange: # pylint: disable=too-few-public-methods
95
+ """
96
+ The recurrence range settings.
97
+ """
98
+
99
+ type: RecurrenceRangeType
100
+ end_date: Optional[datetime] = None
101
+
102
+ def __init__(self, range_data: Dict[str, Any]):
103
+ self.type = RecurrenceRangeType.from_str(range_data.get("Type", "NoEnd"))
104
+ if range_data.get("EndDate") and isinstance(range_data.get("EndDate"), str):
105
+ end_date_str = range_data.get("EndDate", "")
106
+ try:
107
+ self.end_date = parsedate_to_datetime(end_date_str) if end_date_str else None
108
+ except ValueError as e:
109
+ raise ValueError(f"Invalid value for EndDate: {end_date_str}") from e
110
+ except TypeError as e:
111
+ # Python 3.9 and earlier throw TypeError if the string is not in RFC 2822 format.
112
+ raise ValueError(f"Invalid value for EndDate: {end_date_str}") from e
113
+ self.num_of_occurrences = range_data.get("NumberOfOccurrences", 2**63 - 1)
114
+ if self.num_of_occurrences <= 0:
115
+ raise ValueError("The number of occurrences must be greater than 0.")
116
+
117
+
118
+ class Recurrence: # pylint: disable=too-few-public-methods
119
+ """
120
+ The recurrence settings.
121
+ """
122
+
123
+ pattern: RecurrencePattern
124
+ range: RecurrenceRange
125
+
126
+ def __init__(self, recurrence_data: Dict[str, Any]):
127
+ self.pattern = RecurrencePattern(recurrence_data.get("Pattern", {}))
128
+ self.range = RecurrenceRange(recurrence_data.get("Range", {}))
129
+
130
+
131
+ @dataclass
132
+ class TimeWindowFilterSettings:
133
+ """
134
+ The settings for the time window filter.
135
+ """
136
+
137
+ start: Optional[datetime]
138
+ end: Optional[datetime]
139
+ recurrence: Optional[Recurrence]
140
+
141
+
142
+ @dataclass
143
+ class OccurrenceInfo:
144
+ """
145
+ The information of the previous occurrence.
146
+ """
147
+
148
+ previous_occurrence: datetime
149
+ num_of_occurrences: int
@@ -0,0 +1,125 @@
1
+ # ------------------------------------------------------------------------
2
+ # Copyright (c) Microsoft Corporation. All rights reserved.
3
+ # Licensed under the MIT License. See License.txt in the project root for
4
+ # license information.
5
+ # -------------------------------------------------------------------------
6
+ from datetime import datetime, timedelta
7
+ from typing import Optional
8
+ from ._models import RecurrencePatternType, RecurrenceRangeType, TimeWindowFilterSettings, OccurrenceInfo, Recurrence
9
+ from ._recurrence_validator import validate_settings, _get_passed_week_days, _sort_days_of_week
10
+
11
+ DAYS_PER_WEEK = 7
12
+ REQUIRED_PARAMETER = "Required parameter: %s"
13
+
14
+
15
+ def is_match(settings: TimeWindowFilterSettings, now: datetime) -> bool:
16
+ """
17
+ Check if the current time is within the time window filter settings.
18
+
19
+ :param TimeWindowFilterSettings settings: The settings for the time window filter.
20
+ :param datetime now: The current time.
21
+ :return: True if the current time is within the time window filter settings, otherwise False.
22
+ :rtype: bool
23
+ """
24
+ recurrence = settings.recurrence
25
+ if recurrence is None:
26
+ raise ValueError(REQUIRED_PARAMETER % "Recurrence")
27
+
28
+ start = settings.start
29
+ end = settings.end
30
+ if start is None or end is None:
31
+ raise ValueError(REQUIRED_PARAMETER % "Start or End")
32
+
33
+ validate_settings(recurrence, start, end)
34
+
35
+ previous_occurrence = _get_previous_occurrence(recurrence, start, now)
36
+ if previous_occurrence is None:
37
+ return False
38
+
39
+ occurrence_end_date = previous_occurrence + (end - start)
40
+ return now < occurrence_end_date
41
+
42
+
43
+ def _get_previous_occurrence(recurrence: Recurrence, start: datetime, now: datetime) -> Optional[datetime]:
44
+ if now < start:
45
+ return None
46
+
47
+ pattern_type = recurrence.pattern.type
48
+ if pattern_type == RecurrencePatternType.DAILY:
49
+ occurrence_info = _get_daily_previous_occurrence(recurrence, start, now)
50
+ elif pattern_type == RecurrencePatternType.WEEKLY:
51
+ occurrence_info = _get_weekly_previous_occurrence(recurrence, start, now)
52
+ else:
53
+ raise ValueError(f"Unsupported recurrence pattern type: {pattern_type}")
54
+
55
+ recurrence_range = recurrence.range
56
+ range_type = recurrence_range.type
57
+ previous_occurrence = occurrence_info.previous_occurrence
58
+ end_date = recurrence_range.end_date
59
+ if (
60
+ range_type == RecurrenceRangeType.END_DATE
61
+ and previous_occurrence is not None
62
+ and end_date is not None
63
+ and previous_occurrence > end_date
64
+ ):
65
+ return None
66
+ if (
67
+ range_type == RecurrenceRangeType.NUMBERED
68
+ and recurrence_range.num_of_occurrences is not None
69
+ and occurrence_info.num_of_occurrences > recurrence_range.num_of_occurrences
70
+ ):
71
+ return None
72
+
73
+ return occurrence_info.previous_occurrence
74
+
75
+
76
+ def _get_daily_previous_occurrence(recurrence: Recurrence, start: datetime, now: datetime) -> OccurrenceInfo:
77
+ interval = recurrence.pattern.interval
78
+ num_of_occurrences = (now - start).days // interval
79
+ previous_occurrence = start + timedelta(days=num_of_occurrences * interval)
80
+ return OccurrenceInfo(previous_occurrence, num_of_occurrences + 1)
81
+
82
+
83
+ def _get_weekly_previous_occurrence(recurrence: Recurrence, start: datetime, now: datetime) -> OccurrenceInfo:
84
+ pattern = recurrence.pattern
85
+ interval = pattern.interval
86
+ first_day_of_first_week = start - timedelta(days=_get_passed_week_days(start.weekday(), pattern.first_day_of_week))
87
+
88
+ number_of_interval = (now - first_day_of_first_week).days // (interval * DAYS_PER_WEEK)
89
+ first_day_of_most_recent_occurring_week = first_day_of_first_week + timedelta(
90
+ days=number_of_interval * (interval * DAYS_PER_WEEK)
91
+ )
92
+ sorted_days_of_week = _sort_days_of_week(pattern.days_of_week, pattern.first_day_of_week)
93
+ max_day_offset = _get_passed_week_days(sorted_days_of_week[-1], pattern.first_day_of_week)
94
+ min_day_offset = _get_passed_week_days(sorted_days_of_week[0], pattern.first_day_of_week)
95
+ num_of_occurrences = number_of_interval * len(sorted_days_of_week) - sorted_days_of_week.index(start.weekday())
96
+
97
+ if now > first_day_of_most_recent_occurring_week + timedelta(days=DAYS_PER_WEEK):
98
+ num_of_occurrences += len(sorted_days_of_week)
99
+ most_recent_occurrence = first_day_of_most_recent_occurring_week + timedelta(days=max_day_offset)
100
+ return OccurrenceInfo(most_recent_occurrence, num_of_occurrences)
101
+
102
+ day_with_min_offset = first_day_of_most_recent_occurring_week + timedelta(days=min_day_offset)
103
+ if start > day_with_min_offset:
104
+ num_of_occurrences = 0
105
+ day_with_min_offset = start
106
+ if now < day_with_min_offset:
107
+ most_recent_occurrence = (
108
+ first_day_of_most_recent_occurring_week
109
+ - timedelta(days=interval * DAYS_PER_WEEK)
110
+ + timedelta(days=max_day_offset)
111
+ )
112
+ else:
113
+ most_recent_occurrence = day_with_min_offset
114
+ num_of_occurrences += 1
115
+
116
+ for day in sorted_days_of_week[sorted_days_of_week.index(day_with_min_offset.weekday()) + 1 :]:
117
+ day_with_min_offset = first_day_of_most_recent_occurring_week + timedelta(
118
+ days=_get_passed_week_days(day, pattern.first_day_of_week)
119
+ )
120
+ if now < day_with_min_offset:
121
+ break
122
+ most_recent_occurrence = day_with_min_offset
123
+ num_of_occurrences += 1
124
+
125
+ return OccurrenceInfo(most_recent_occurrence, num_of_occurrences)