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.
- featuremanagement-2.1.0/CHANGELOG.md +3 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0/FeatureManagement.egg-info}/PKG-INFO +6 -2
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/FeatureManagement.egg-info/SOURCES.txt +10 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/FeatureManagement.egg-info/top_level.txt +1 -0
- {featuremanagement-2.0.0b3/FeatureManagement.egg-info → featuremanagement-2.1.0}/PKG-INFO +6 -2
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_defaultfilters.py +34 -6
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_featuremanager.py +23 -3
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_featuremanagerbase.py +12 -4
- featuremanagement-2.1.0/featuremanagement/_time_window_filter/__init__.py +9 -0
- featuremanagement-2.1.0/featuremanagement/_time_window_filter/_models.py +149 -0
- featuremanagement-2.1.0/featuremanagement/_time_window_filter/_recurrence_evaluator.py +125 -0
- featuremanagement-2.1.0/featuremanagement/_time_window_filter/_recurrence_validator.py +164 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_version.py +1 -1
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/aio/_featuremanager.py +27 -3
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/azuremonitor/__init__.py +2 -1
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/azuremonitor/_send_telemetry.py +61 -9
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/pyproject.toml +1 -1
- featuremanagement-2.1.0/samples/feature_variant_sample_with_targeting_accessor.py +36 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/samples/formatted_feature_flags.json +1 -0
- featuremanagement-2.1.0/samples/quarty_sample.py +60 -0
- featuremanagement-2.1.0/tests/__init__.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/tests/test_default_feature_flags.py +49 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/tests/test_default_feature_flags_async.py +50 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/tests/test_send_telemetry_appinsights.py +51 -14
- featuremanagement-2.1.0/tests/time_window_filter/test_recurrence_evaluator.py +281 -0
- featuremanagement-2.1.0/tests/time_window_filter/test_recurrence_validator.py +321 -0
- featuremanagement-2.1.0/tests/time_window_filter/test_time_window_filter_models.py +133 -0
- featuremanagement-2.0.0b3/CHANGELOG.md +0 -32
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/CODE_OF_CONDUCT.md +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/FeatureManagement.egg-info/dependency_links.txt +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/FeatureManagement.egg-info/not-zip-safe +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/FeatureManagement.egg-info/requires.txt +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/LICENSE +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/MANIFEST.in +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/README.md +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/SECURITY.md +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/SUPPORT.md +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/__init__.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_featurefilters.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_models/__init__.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_models/_allocation.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_models/_constants.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_models/_evaluation_event.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_models/_feature_conditions.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_models/_feature_flag.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_models/_targeting_context.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_models/_telemetry.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_models/_variant.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_models/_variant_assignment_reason.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_models/_variant_reference.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/aio/__init__.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/aio/_defaultfilters.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/aio/_featurefilters.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/py.typed +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/samples/feature_flag_sample.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/samples/feature_flag_with_azure_app_configuration_sample.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/samples/feature_variant_sample.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/samples/feature_variant_sample_with_telemetry.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/samples/random_filter.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/setup.cfg +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/setup.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/tests/test_feature_manager.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/tests/test_feature_manager_async.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/tests/test_feature_manager_refresh.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/tests/test_feature_variants.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/tests/test_feature_variants_async.py +0 -0
- {featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/tests/validation_tests/test_json_validations.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: FeatureManagement
|
|
3
|
-
Version: 2.0
|
|
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
|
|
{featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/FeatureManagement.egg-info/SOURCES.txt
RENAMED
|
@@ -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
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: FeatureManagement
|
|
3
|
-
Version: 2.0
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
{featuremanagement-2.0.0b3 → featuremanagement-2.1.0}/featuremanagement/_featuremanagerbase.py
RENAMED
|
@@ -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
|
|
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
|
-
|
|
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)
|