FeatureManagement 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Microsoft Corporation.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.1
2
+ Name: FeatureManagement
3
+ Version: 1.0.0
4
+ Summary: A library for enabling/disabling features at runtime.
5
+ Home-page: https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/appconfiguration/feature-management
6
+ Author: Microsoft Corporation
7
+ Author-email: Microsoft Corporation <appconfig@microsoft.com>
8
+ License: MIT License
9
+
10
+ Copyright (c) Microsoft Corporation.
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE
29
+
30
+ Project-URL: Homepage, https://github.com/microsoft/FeatureManagement-Python
31
+ Project-URL: Issues, https://github.com/microsoft/FeatureManagement-Python/issues
32
+ Classifier: Development Status :: 5 - Production/Stable
33
+ Classifier: Programming Language :: Python
34
+ Classifier: Programming Language :: Python :: 3 :: Only
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3.8
37
+ Classifier: Programming Language :: Python :: 3.9
38
+ Classifier: Programming Language :: Python :: 3.10
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: License :: OSI Approved :: MIT License
41
+ Requires-Python: >=3.6
42
+ Description-Content-Type: text/markdown
43
+ License-File: LICENSE
44
+ Provides-Extra: appinsights
45
+ Requires-Dist: azure-monitor-opentelemetry <2.0.0,>=1.3.0 ; extra == 'appinsights'
46
+ Requires-Dist: azure-monitor-events-extension <2.0.0 ; extra == 'appinsights'
47
+
48
+ # Microsoft Feature Management for Python
49
+
50
+ [![FeatureManagement](https://img.shields.io/pypi/v/FeatureManagement?label=FeatureManagement)](https://pypi.org/project/FeatureManagement/)
51
+
52
+ Feature management provides a way to develop and expose application functionality based on features. Many applications have special requirements when a new feature is developed such as when the feature should be enabled and under what conditions. This library provides a way to define these relationships, and also integrates into common Python code patterns to make exposing these features possible.
53
+
54
+ ## Get Started
55
+
56
+ [Quickstart](https://learn.microsoft.com/azure/azure-app-configuration/quickstart-feature-flag-python): A quickstart guide is available to learn how to integrate feature flags from Azure App Configuration into your Python applications.
57
+
58
+ [API Reference](https://microsoft.github.io/FeatureManagement-Python/): This API reference details the API surface of the libraries contained within this repository.
59
+
60
+ ## Examples
61
+
62
+ * [Python Application](https://github.com/microsoft/FeatureManagement-Python/blob/main/samples/feature_flag_sample.py)
63
+ * [Python Application with Azure App Configuration](https://github.com/microsoft/FeatureManagement-Python/blob/main/samples/feature_flag_with_azure_app_configuration_sample.py)
64
+ * [Django Application](https://github.com/Azure/AppConfiguration/tree/main/examples/Python/python-django-webapp-sample)
65
+ * [Flask Application](https://github.com/Azure/AppConfiguration/tree/main/examples/Python/python-flask-webapp-sample)
66
+
67
+ ## Contributing
68
+
69
+ This project welcomes contributions and suggestions. Most contributions require you to agree to a
70
+ Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us
71
+ the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com.
72
+
73
+ When you submit a pull request, a CLA bot will automatically determine whether you need to provide
74
+ a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions
75
+ provided by the bot. You will only need to do this once across all repos using our CLA.
76
+
77
+ This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
78
+ For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or
79
+ contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.
80
+
81
+ ## Trademarks
82
+
83
+ This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft
84
+ trademarks or logos is subject to and must follow
85
+ [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general).
86
+ Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship.
87
+ Any use of third-party trademarks or logos are subject to those third-party's policies.
@@ -0,0 +1,21 @@
1
+ featuremanagement/__init__.py,sha256=h5PFIFzLm68Gt_KDtJeTgdh88XwrOUZvV5v9Clpd8qM,678
2
+ featuremanagement/_defaultfilters.py,sha256=NAc85ckoD3gfnl4SXvRZNQ9gN6iIPwOz4vJX_PHJv9M,6121
3
+ featuremanagement/_featurefilters.py,sha256=xuvXzw4p6ECkHavDpnnvedJhvGLhTHtEWK4kdwKhM_M,1294
4
+ featuremanagement/_featuremanager.py,sha256=uDMbl2w0SSt7isBRnY0P0ScaMXzqC86D5yl-CZQX40E,7661
5
+ featuremanagement/_version.py,sha256=ArUlT5NPQY8GDMm3itZPB_sXzGtCLRvso3E0KoXHegA,327
6
+ featuremanagement/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ featuremanagement/_models/__init__.py,sha256=S-xzmIgVGw7GO1lZGob8h_9WBXpXFNg98kJypZ8uSbk,591
8
+ featuremanagement/_models/_constants.py,sha256=puNDkZHUoB2eCOQ4CeP4LL9xwogPdIP9uRAmTpOSM9c,621
9
+ featuremanagement/_models/_evaluation_event.py,sha256=p2OGowvhY5cSHs6LhpsziOAtztn29Z5EwGWg1OECtDg,713
10
+ featuremanagement/_models/_feature_conditions.py,sha256=hNEslIhv1Zdiue4mXhuLZNg0uQdw5deyhoFAgCcgxX0,2482
11
+ featuremanagement/_models/_feature_flag.py,sha256=JC4AW7BsL17YELJ4cVZSRjnYJPFODOGqKgBmg10Ik0I,3139
12
+ featuremanagement/_models/_targeting_context.py,sha256=pD8yc7dO94WcC0TKW_Z3pGoMuDEv8hygXVlVd6lrpFI,626
13
+ featuremanagement/aio/__init__.py,sha256=-7pTXT_eszJr2Mc34lTS1ICuI67xZ9lDpZ0C01uKbCY,544
14
+ featuremanagement/aio/_defaultfilters.py,sha256=QCrwlzH0738o2nTDmj8XXPgcCrAmxHM8HB11qVJqawA,1732
15
+ featuremanagement/aio/_featurefilters.py,sha256=eF7JXO9Sdxu8rIZivOyWQIc8NDaK-FBGMTEOvQcwqAo,1305
16
+ featuremanagement/aio/_featuremanager.py,sha256=i2LyqTByT5FRGq_3txuondUKufp6g3KQC6H4Pan7oOQ,6141
17
+ FeatureManagement-1.0.0.dist-info/LICENSE,sha256=ws_MuBL-SCEBqPBFl9_FqZkaaydIJmxHrJG2parhU4M,1141
18
+ FeatureManagement-1.0.0.dist-info/METADATA,sha256=LUag-0GF-s5EcxR1fUqKZ6-ArJxqGWDwdIPDSk-KptE,5543
19
+ FeatureManagement-1.0.0.dist-info/WHEEL,sha256=mguMlWGMX-VHnMpKOjjQidIo1ssRlCFu4a4mBpz1s2M,91
20
+ FeatureManagement-1.0.0.dist-info/top_level.txt,sha256=S0iNu3dPTzNkYFDt3OHOEQXs-gQf1cQaw4Usl7kgPdI,18
21
+ FeatureManagement-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (70.1.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ featuremanagement
@@ -0,0 +1,20 @@
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 ._featuremanager import FeatureManager
7
+ from ._featurefilters import FeatureFilter
8
+ from ._defaultfilters import TimeWindowFilter, TargetingFilter
9
+ from ._models import TargetingContext
10
+
11
+ from ._version import VERSION
12
+
13
+ __version__ = VERSION
14
+ __all__ = [
15
+ "FeatureManager",
16
+ "TimeWindowFilter",
17
+ "TargetingFilter",
18
+ "FeatureFilter",
19
+ "TargetingContext",
20
+ ]
@@ -0,0 +1,162 @@
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
+ import logging
7
+ import hashlib
8
+
9
+ from datetime import datetime, timezone
10
+ from email.utils import parsedate_to_datetime
11
+
12
+ from ._featurefilters import FeatureFilter
13
+
14
+ FEATURE_FLAG_NAME_KEY = "feature_name"
15
+ ROLLOUT_PERCENTAGE_KEY = "RolloutPercentage"
16
+ DEFAULT_ROLLOUT_PERCENTAGE_KEY = "DefaultRolloutPercentage"
17
+ PARAMETERS_KEY = "parameters"
18
+
19
+ # Time Window Constants
20
+ START_KEY = "Start"
21
+ END_KEY = "End"
22
+
23
+ # Targeting kwargs
24
+ TARGETED_USER_KEY = "user"
25
+ TARGETED_GROUPS_KEY = "groups"
26
+
27
+ # Targeting Constants
28
+ AUDIENCE_KEY = "Audience"
29
+ USERS_KEY = "Users"
30
+ GROUPS_KEY = "Groups"
31
+ EXCLUSION_KEY = "Exclusion"
32
+ FEATURE_FILTER_NAME_KEY = "Name"
33
+ IGNORE_CASE_KEY = "ignore_case"
34
+
35
+
36
+ class TargetingException(Exception):
37
+ """
38
+ Exception raised when the targeting filter is not configured correctly.
39
+ """
40
+
41
+
42
+ @FeatureFilter.alias("Microsoft.TimeWindow")
43
+ class TimeWindowFilter(FeatureFilter):
44
+ """
45
+ Feature Filter that determines if the current time is within the time window.
46
+ """
47
+
48
+ def evaluate(self, context, **kwargs):
49
+ """
50
+ Determine if the feature flag is enabled for the given context.
51
+
52
+ :keyword Mapping context: Mapping with the Start and End time for the feature flag.
53
+ :return: True if the current time is within the time window.
54
+ :rtype: bool
55
+ """
56
+ start = context.get(PARAMETERS_KEY, {}).get(START_KEY)
57
+ end = context.get(PARAMETERS_KEY, {}).get(END_KEY)
58
+
59
+ current_time = datetime.now(timezone.utc)
60
+
61
+ if not start and not end:
62
+ logging.warning("%s: At least one of Start or End is required.", TimeWindowFilter.__name__)
63
+ return False
64
+
65
+ start_time = parsedate_to_datetime(start) if start else None
66
+ end_time = parsedate_to_datetime(end) if end else None
67
+
68
+ return (start_time is None or start_time <= current_time) and (end_time is None or current_time < end_time)
69
+
70
+
71
+ @FeatureFilter.alias("Microsoft.Targeting")
72
+ class TargetingFilter(FeatureFilter):
73
+ """
74
+ Feature Filter that determines if the user is targeted for the feature flag.
75
+ """
76
+
77
+ @staticmethod
78
+ def _is_targeted(context_id, rollout_percentage):
79
+ """Determine if the user is targeted for the given context"""
80
+ # Always return true if rollout percentage is 100
81
+ if rollout_percentage == 100:
82
+ return True
83
+
84
+ hashed_context_id = hashlib.sha256(context_id.encode()).digest()
85
+ context_marker = int.from_bytes(hashed_context_id[:4], byteorder="little", signed=False)
86
+
87
+ percentage = (context_marker / (2**32 - 1)) * 100
88
+ return percentage < rollout_percentage
89
+
90
+ def _target_group(self, target_user, target_group, group, feature_flag_name):
91
+ group_rollout_percentage = group.get(ROLLOUT_PERCENTAGE_KEY, 0)
92
+ if not target_user:
93
+ target_user = ""
94
+ audience_context_id = target_user + "\n" + feature_flag_name + "\n" + target_group
95
+
96
+ return self._is_targeted(audience_context_id, group_rollout_percentage)
97
+
98
+ def evaluate(self, context, **kwargs):
99
+ """
100
+ Determine if the feature flag is enabled for the given context.
101
+
102
+ :keyword Mapping context: Context for evaluating the user/group.
103
+ :return: True if the user is targeted for the feature flag.
104
+ :rtype: bool
105
+ """
106
+ target_user = kwargs.get(TARGETED_USER_KEY, None)
107
+ target_groups = kwargs.get(TARGETED_GROUPS_KEY, [])
108
+
109
+ if not target_user and not (target_groups and len(target_groups) > 0):
110
+ logging.warning("%s: Name or Groups are required parameters", TargetingFilter.__name__)
111
+ return False
112
+
113
+ audience = context.get(PARAMETERS_KEY, {}).get(AUDIENCE_KEY, None)
114
+ feature_flag_name = context.get(FEATURE_FLAG_NAME_KEY, None)
115
+
116
+ if not audience:
117
+ raise TargetingException("Audience is required for " + TargetingFilter.__name__)
118
+
119
+ groups = audience.get(GROUPS_KEY, [])
120
+ default_rollout_percentage = audience.get(DEFAULT_ROLLOUT_PERCENTAGE_KEY, 0)
121
+
122
+ self._validate(groups, default_rollout_percentage)
123
+
124
+ # Check if the user is excluded
125
+ if target_user in audience.get(EXCLUSION_KEY, {}).get(USERS_KEY, []):
126
+ return False
127
+
128
+ # Check if the user is in an excluded group
129
+ for group in audience.get(EXCLUSION_KEY, {}).get(GROUPS_KEY, []):
130
+ if group in target_groups:
131
+ return False
132
+
133
+ # Check if the user is targeted
134
+ if target_user in audience.get(USERS_KEY, []):
135
+ return True
136
+
137
+ # Check if the user is in a targeted group
138
+ for group in groups:
139
+ for target_group in target_groups:
140
+ group_name = group.get(FEATURE_FILTER_NAME_KEY, "")
141
+ if kwargs.get(IGNORE_CASE_KEY, False):
142
+ target_group = target_group.lower()
143
+ group_name = group_name.lower()
144
+ if group_name == target_group:
145
+ if self._target_group(target_user, target_group, group, feature_flag_name):
146
+ return True
147
+
148
+ if not target_user:
149
+ target_user = ""
150
+ # Check if the user is in the default rollout
151
+ context_id = target_user + "\n" + feature_flag_name
152
+ return self._is_targeted(context_id, default_rollout_percentage)
153
+
154
+ @staticmethod
155
+ def _validate(groups, default_rollout_percentage):
156
+ # Validate the audience settings
157
+ if default_rollout_percentage < 0 or default_rollout_percentage > 100:
158
+ raise TargetingException("DefaultRolloutPercentage must be between 0 and 100")
159
+
160
+ for group in groups:
161
+ if group.get(ROLLOUT_PERCENTAGE_KEY) < 0 or group.get(ROLLOUT_PERCENTAGE_KEY) > 100:
162
+ raise TargetingException("RolloutPercentage must be between 0 and 100")
@@ -0,0 +1,48 @@
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 abc import ABC, abstractmethod
7
+
8
+
9
+ class FeatureFilter(ABC):
10
+ """
11
+ Parent class for all feature filters.
12
+ """
13
+
14
+ @abstractmethod
15
+ def evaluate(self, context, **kwargs):
16
+ """
17
+ Determine if the feature flag is enabled for the given context.
18
+
19
+ :param Mapping context: Context for the feature flag.
20
+ """
21
+
22
+ @property
23
+ def name(self):
24
+ """
25
+ Get the name of the filter.
26
+
27
+ :return: Name of the filter, or alias if it exists.
28
+ :rtype: str
29
+ """
30
+ if hasattr(self, "_alias"):
31
+ return self._alias
32
+ return self.__class__.__name__
33
+
34
+ @staticmethod
35
+ def alias(alias):
36
+ """
37
+ Decorator to set the alias for the filter.
38
+
39
+ :param str alias: Alias for the filter.
40
+ :return: Decorator.
41
+ :rtype: callable
42
+ """
43
+
44
+ def wrapper(cls):
45
+ cls._alias = alias # pylint: disable=protected-access
46
+ return cls
47
+
48
+ return wrapper
@@ -0,0 +1,190 @@
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
+ import logging
7
+ from collections.abc import Mapping
8
+ from typing import overload
9
+ from ._defaultfilters import TimeWindowFilter, TargetingFilter
10
+ from ._featurefilters import FeatureFilter
11
+ from ._models import FeatureFlag, EvaluationEvent, TargetingContext
12
+
13
+
14
+ FEATURE_MANAGEMENT_KEY = "feature_management"
15
+ FEATURE_FLAG_KEY = "feature_flags"
16
+
17
+ PROVIDED_FEATURE_FILTERS = "feature_filters"
18
+ FEATURE_FILTER_NAME = "name"
19
+ REQUIREMENT_TYPE_ALL = "All"
20
+ REQUIREMENT_TYPE_ANY = "Any"
21
+
22
+ FEATURE_FILTER_PARAMETERS = "parameters"
23
+
24
+
25
+ def _get_feature_flag(configuration, feature_flag_name):
26
+ """
27
+ Gets the FeatureFlag json from the configuration, if it exists it gets converted to a FeatureFlag object.
28
+
29
+ :param Mapping configuration: Configuration object.
30
+ :param str feature_flag_name: Name of the feature flag.
31
+ :return: FeatureFlag
32
+ :rtype: FeatureFlag
33
+ """
34
+ feature_management = configuration.get(FEATURE_MANAGEMENT_KEY)
35
+ if not feature_management or not isinstance(feature_management, Mapping):
36
+ return None
37
+ feature_flags = feature_management.get(FEATURE_FLAG_KEY)
38
+ if not feature_flags or not isinstance(feature_flags, list):
39
+ return None
40
+
41
+ for feature_flag in feature_flags:
42
+ if feature_flag.get("id") == feature_flag_name:
43
+ return FeatureFlag.convert_from_json(feature_flag)
44
+
45
+ return None
46
+
47
+
48
+ def _list_feature_flag_names(configuration):
49
+ """
50
+ List of all feature flag names.
51
+
52
+ :param Mapping configuration: Configuration object.
53
+ :return: List of feature flag names.
54
+ """
55
+ feature_flag_names = []
56
+ feature_management = configuration.get(FEATURE_MANAGEMENT_KEY)
57
+ if not feature_management or not isinstance(feature_management, Mapping):
58
+ return []
59
+ feature_flags = feature_management.get(FEATURE_FLAG_KEY)
60
+ if not feature_flags or not isinstance(feature_flags, list):
61
+ return []
62
+
63
+ for feature_flag in feature_flags:
64
+ feature_flag_names.append(feature_flag.get("id"))
65
+
66
+ return feature_flag_names
67
+
68
+
69
+ class FeatureManager:
70
+ """
71
+ Feature Manager that determines if a feature flag is enabled for the given context.
72
+
73
+ :param Mapping configuration: Configuration object.
74
+ :keyword list[FeatureFilter] feature_filters: Custom filters to be used for evaluating feature flags.
75
+ """
76
+
77
+ def __init__(self, configuration, **kwargs):
78
+ self._filters = {}
79
+ if configuration is None or not isinstance(configuration, Mapping):
80
+ raise AttributeError("Configuration must be a non-empty dictionary")
81
+ self._configuration = configuration
82
+ self._cache = {}
83
+ self._copy = configuration.get(FEATURE_MANAGEMENT_KEY)
84
+ filters = [TimeWindowFilter(), TargetingFilter()] + kwargs.pop(PROVIDED_FEATURE_FILTERS, [])
85
+
86
+ for feature_filter in filters:
87
+ if not isinstance(feature_filter, FeatureFilter):
88
+ raise ValueError("Custom filter must be a subclass of FeatureFilter")
89
+ self._filters[feature_filter.name] = feature_filter
90
+
91
+ def _build_targeting_context(self, args):
92
+ """
93
+ Builds a TargetingContext, either returns a provided context, takes the provided user_id to make a context, or
94
+ returns an empty context.
95
+
96
+ :param args: Arguments to build the TargetingContext.
97
+ :return: TargetingContext
98
+ """
99
+ if len(args) == 1 and isinstance(args[0], str):
100
+ return TargetingContext(user_id=args[0], groups=[])
101
+ if len(args) == 1 and isinstance(args[0], TargetingContext):
102
+ return args[0]
103
+ return TargetingContext()
104
+
105
+ @overload
106
+ def is_enabled(self, feature_flag_id, user_id, **kwargs):
107
+ """
108
+ Determine if the feature flag is enabled for the given context.
109
+
110
+ :param str feature_flag_id: Name of the feature flag.
111
+ :param str user_id: User identifier.
112
+ :return: True if the feature flag is enabled for the given context.
113
+ :rtype: bool
114
+ """
115
+
116
+ def is_enabled(self, feature_flag_id, *args, **kwargs):
117
+ """
118
+ Determine if the feature flag is enabled for the given context.
119
+
120
+ :param str feature_flag_id: Name of the feature flag.
121
+ :return: True if the feature flag is enabled for the given context.
122
+ :rtype: bool
123
+ """
124
+ targeting_context = self._build_targeting_context(args)
125
+
126
+ result = self._check_feature(feature_flag_id, targeting_context, **kwargs)
127
+ return result.enabled
128
+
129
+ def _check_feature_filters(self, feature_flag, targeting_context, **kwargs):
130
+ feature_conditions = feature_flag.conditions
131
+ feature_filters = feature_conditions.client_filters
132
+ evaluation_event = EvaluationEvent(enabled=False)
133
+
134
+ if len(feature_filters) == 0:
135
+ # Feature flags without any filters return evaluate
136
+ evaluation_event.enabled = True
137
+ else:
138
+ # The assumed value is no filters is based on the requirement type.
139
+ # Requirement type Any assumes false until proven true, All assumes true until proven false
140
+ evaluation_event.enabled = feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL
141
+
142
+ for feature_filter in feature_filters:
143
+ filter_name = feature_filter[FEATURE_FILTER_NAME]
144
+ kwargs["user"] = targeting_context.user_id
145
+ kwargs["groups"] = targeting_context.groups
146
+ if filter_name not in self._filters:
147
+ raise ValueError(f"Feature flag {feature_flag.name} has unknown filter {filter_name}")
148
+ if feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL:
149
+ if not self._filters[filter_name].evaluate(feature_filter, **kwargs):
150
+ evaluation_event.enabled = False
151
+ break
152
+ elif self._filters[filter_name].evaluate(feature_filter, **kwargs):
153
+ evaluation_event.enabled = True
154
+ break
155
+ return evaluation_event
156
+
157
+ def _check_feature(self, feature_flag_id, targeting_context, **kwargs):
158
+ """
159
+ Determine if the feature flag is enabled for the given context.
160
+
161
+ :param str feature_flag_id: Name of the feature flag.
162
+ :return: True if the feature flag is enabled for the given context.
163
+ :rtype: bool
164
+ """
165
+ if self._copy is not self._configuration.get(FEATURE_MANAGEMENT_KEY):
166
+ self._cache = {}
167
+ self._copy = self._configuration.get(FEATURE_MANAGEMENT_KEY)
168
+
169
+ if not self._cache.get(feature_flag_id):
170
+ feature_flag = _get_feature_flag(self._configuration, feature_flag_id)
171
+ self._cache[feature_flag_id] = feature_flag
172
+ else:
173
+ feature_flag = self._cache.get(feature_flag_id)
174
+
175
+ if not feature_flag:
176
+ logging.warning("Feature flag %s not found", feature_flag_id)
177
+ # Unknown feature flags are disabled by default
178
+ return EvaluationEvent(enabled=False)
179
+
180
+ if not feature_flag.enabled:
181
+ # Feature flags that are disabled are always disabled
182
+ return EvaluationEvent(enabled=False)
183
+
184
+ return self._check_feature_filters(feature_flag, targeting_context, **kwargs)
185
+
186
+ def list_feature_flag_names(self):
187
+ """
188
+ List of all feature flag names.
189
+ """
190
+ return _list_feature_flag_names(self._configuration)
@@ -0,0 +1,12 @@
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 ._feature_flag import FeatureFlag
7
+ from ._evaluation_event import EvaluationEvent
8
+ from ._targeting_context import TargetingContext
9
+
10
+ __path__ = __import__("pkgutil").extend_path(__path__, __name__) # type: ignore
11
+
12
+ __all__ = ["FeatureFlag", "EvaluationEvent", "TargetingContext"]
@@ -0,0 +1,18 @@
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
+
7
+ # Feature Flag
8
+ FEATURE_FLAG_ID = "id"
9
+ FEATURE_FLAG_ENABLED = "enabled"
10
+ FEATURE_FLAG_CONDITIONS = "conditions"
11
+
12
+
13
+ # Conditions
14
+ FEATURE_FILTER_REQUIREMENT_TYPE = "requirement_type"
15
+ REQUIREMENT_TYPE_ALL = "All"
16
+ REQUIREMENT_TYPE_ANY = "Any"
17
+ FEATURE_FLAG_CLIENT_FILTERS = "client_filters"
18
+ FEATURE_FILTER_NAME = "name"
@@ -0,0 +1,23 @@
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 dataclasses import dataclass
7
+
8
+
9
+ @dataclass
10
+ class EvaluationEvent:
11
+ """
12
+ Represents a feature flag evaluation event.
13
+ """
14
+
15
+ def __init__(self, *, enabled=False, feature_flag=None):
16
+ """
17
+ Initialize the EvaluationEvent.
18
+ """
19
+ self.feature = feature_flag
20
+ self.user = ""
21
+ self.enabled = enabled
22
+ self.variant = None
23
+ self.reason = None
@@ -0,0 +1,70 @@
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 collections.abc import Mapping
7
+ from ._constants import (
8
+ FEATURE_FLAG_CLIENT_FILTERS,
9
+ FEATURE_FILTER_NAME,
10
+ FEATURE_FILTER_REQUIREMENT_TYPE,
11
+ REQUIREMENT_TYPE_ALL,
12
+ REQUIREMENT_TYPE_ANY,
13
+ )
14
+
15
+
16
+ class FeatureConditions:
17
+ """
18
+ Represents the conditions for a feature flag.
19
+ """
20
+
21
+ def __init__(self):
22
+ self._requirement_type = REQUIREMENT_TYPE_ANY
23
+ self._client_filters = []
24
+
25
+ @classmethod
26
+ def convert_from_json(cls, feature_name, json_value):
27
+ """
28
+ Convert a JSON object to FeatureConditions.
29
+
30
+ :param dict json: JSON object.
31
+ :return: FeatureConditions.
32
+ :rtype: FeatureConditions
33
+ """
34
+ conditions = cls()
35
+ if json_value is not None and not isinstance(json_value, Mapping):
36
+ raise AttributeError("Feature flag conditions must be a dictionary")
37
+ conditions._requirement_type = json_value.get(FEATURE_FILTER_REQUIREMENT_TYPE, REQUIREMENT_TYPE_ANY)
38
+ conditions._client_filters = json_value.get(FEATURE_FLAG_CLIENT_FILTERS, [])
39
+ if not isinstance(conditions._client_filters, list):
40
+ conditions._client_filters = []
41
+ for feature_filter in conditions._client_filters:
42
+ feature_filter["feature_name"] = feature_name
43
+ return conditions
44
+
45
+ @property
46
+ def requirement_type(self):
47
+ """
48
+ Get the requirement type for the feature flag.
49
+
50
+ :return: Requirement type.
51
+ :rtype: str
52
+ """
53
+ return self._requirement_type
54
+
55
+ @property
56
+ def client_filters(self):
57
+ """
58
+ Get the client filters for the feature flag.
59
+
60
+ :return: Client filters.
61
+ :rtype: list[dict]
62
+ """
63
+ return self._client_filters
64
+
65
+ def _validate(self, feature_flag_id):
66
+ if self._requirement_type not in [REQUIREMENT_TYPE_ALL, REQUIREMENT_TYPE_ANY]:
67
+ raise ValueError(f"Feature flag {feature_flag_id} has invalid requirement type.")
68
+ for feature_filter in self._client_filters:
69
+ if feature_filter.get(FEATURE_FILTER_NAME) is None:
70
+ raise ValueError(f"Feature flag {feature_flag_id} is missing filter name.")
@@ -0,0 +1,102 @@
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 ._feature_conditions import FeatureConditions
7
+ from ._constants import (
8
+ FEATURE_FLAG_ID,
9
+ FEATURE_FLAG_ENABLED,
10
+ FEATURE_FLAG_CONDITIONS,
11
+ )
12
+
13
+
14
+ class FeatureFlag:
15
+ """
16
+ Represents a feature flag.
17
+ """
18
+
19
+ def __init__(self):
20
+ self._id = None
21
+ self._enabled = False
22
+ self._conditions = FeatureConditions()
23
+
24
+ @classmethod
25
+ def convert_from_json(cls, json_value):
26
+ """
27
+ Convert a JSON object to FeatureFlag.
28
+
29
+ :param dict json_value: JSON object
30
+ :return: FeatureFlag.
31
+ :rtype: FeatureFlag
32
+ """
33
+ feature_flag = cls()
34
+ if not isinstance(json_value, dict):
35
+ raise ValueError("Feature flag must be a dictionary.")
36
+ feature_flag._id = json_value.get(FEATURE_FLAG_ID)
37
+ feature_flag._enabled = _convert_boolean_value(json_value.get(FEATURE_FLAG_ENABLED, False))
38
+ feature_flag._conditions = FeatureConditions.convert_from_json(
39
+ feature_flag._id, json_value.get(FEATURE_FLAG_CONDITIONS, {})
40
+ )
41
+ if FEATURE_FLAG_CONDITIONS in json_value:
42
+ feature_flag._conditions = FeatureConditions.convert_from_json(
43
+ feature_flag._id, json_value.get(FEATURE_FLAG_CONDITIONS, {})
44
+ )
45
+ else:
46
+ feature_flag._conditions = FeatureConditions()
47
+ feature_flag._validate()
48
+ return feature_flag
49
+
50
+ @property
51
+ def name(self):
52
+ """
53
+ Get the name of the feature flag.
54
+
55
+ :return: Name of the feature flag.
56
+ :rtype: str
57
+ """
58
+ return self._id
59
+
60
+ @property
61
+ def enabled(self):
62
+ """
63
+ Get the status of the feature flag.
64
+
65
+ :return: Status of the feature flag.
66
+ :rtype: bool
67
+ """
68
+ return self._enabled
69
+
70
+ @property
71
+ def conditions(self):
72
+ """
73
+ Get the conditions for the feature flag.
74
+
75
+ :return: Conditions for the feature flag.
76
+ :rtype: FeatureConditions
77
+ """
78
+ return self._conditions
79
+
80
+ def _validate(self):
81
+ if not isinstance(self._id, str):
82
+ raise ValueError(f"Invalid setting 'id' with value '{self._id}' for feature '{self._id}'.")
83
+ if not isinstance(self._enabled, bool):
84
+ raise ValueError(f"Invalid setting 'enabled' with value '{self._enabled}' for feature '{self._id}'.")
85
+ self.conditions._validate(self._id) # pylint: disable=protected-access
86
+
87
+
88
+ def _convert_boolean_value(enabled):
89
+ """
90
+ Convert the value to a boolean if it is a string.
91
+
92
+ :param Union[str, bool] enabled: Value to be converted.
93
+ :return: Converted value.
94
+ :rtype: bool
95
+ """
96
+ if isinstance(enabled, bool):
97
+ return enabled
98
+ if enabled.lower() == "true":
99
+ return True
100
+ if enabled.lower() == "false":
101
+ return False
102
+ return enabled
@@ -0,0 +1,27 @@
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
+
7
+ from typing import NamedTuple, List
8
+
9
+
10
+ class TargetingContext(NamedTuple):
11
+ """
12
+ Represents the context for targeting a feature flag.
13
+ """
14
+
15
+ user_id: str = ""
16
+ """
17
+ The user ID.
18
+
19
+ :type: str
20
+ """
21
+
22
+ groups: List[str] = []
23
+ """
24
+ The users groups.
25
+
26
+ :type: List[str]
27
+ """
@@ -0,0 +1,7 @@
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
+
7
+ VERSION = "1.0.0"
@@ -0,0 +1,10 @@
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 ._featuremanager import FeatureManager
7
+ from ._featurefilters import FeatureFilter
8
+ from ._defaultfilters import TimeWindowFilter, TargetingFilter
9
+
10
+ __all__ = ["FeatureManager", "TimeWindowFilter", "TargetingFilter", "FeatureFilter"]
@@ -0,0 +1,51 @@
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
+
7
+ from ._featurefilters import FeatureFilter
8
+ from .._defaultfilters import (
9
+ TargetingFilter as SyncTargetingFilter,
10
+ TimeWindowFilter as SyncTimeWindowFilter,
11
+ )
12
+
13
+
14
+ @FeatureFilter.alias("Microsoft.TimeWindow")
15
+ class TimeWindowFilter(FeatureFilter):
16
+ """
17
+ Feature Filter that determines if the current time is within the time window.
18
+ """
19
+
20
+ def __init__(self):
21
+ self._filter = SyncTimeWindowFilter()
22
+
23
+ async def evaluate(self, context, **kwargs):
24
+ """
25
+ Determine if the feature flag is enabled for the given context.
26
+
27
+ :keyword Mapping context: Mapping with the Start and End time for the feature flag.
28
+ :return: True if the current time is within the time window.
29
+ :rtype: bool
30
+ """
31
+ return self._filter.evaluate(context, **kwargs)
32
+
33
+
34
+ @FeatureFilter.alias("Microsoft.Targeting")
35
+ class TargetingFilter(FeatureFilter):
36
+ """
37
+ Feature Filter that determines if the user is targeted for the feature flag.
38
+ """
39
+
40
+ def __init__(self):
41
+ self._filter = SyncTargetingFilter()
42
+
43
+ async def evaluate(self, context, **kwargs):
44
+ """
45
+ Determine if the feature flag is enabled for the given context.
46
+
47
+ :keyword Mapping context: Context for evaluating the user/group.
48
+ :return: True if the user is targeted for the feature flag.
49
+ :rtype: bool
50
+ """
51
+ return self._filter.evaluate(context, **kwargs)
@@ -0,0 +1,48 @@
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 abc import ABC, abstractmethod
7
+
8
+
9
+ class FeatureFilter(ABC):
10
+ """
11
+ Parent class for all async feature filters.
12
+ """
13
+
14
+ @abstractmethod
15
+ async def evaluate(self, context, **kwargs):
16
+ """
17
+ Determine if the feature flag is enabled for the given context.
18
+
19
+ :param Mapping context: Context for the feature flag.
20
+ """
21
+
22
+ @property
23
+ def name(self):
24
+ """
25
+ Get the name of the filter.
26
+
27
+ :return: Name of the filter, or alias if it exists.
28
+ :rtype: str
29
+ """
30
+ if hasattr(self, "_alias"):
31
+ return self._alias
32
+ return self.__class__.__name__
33
+
34
+ @staticmethod
35
+ def alias(alias):
36
+ """
37
+ Decorator to set the alias for the filter.
38
+
39
+ :param str alias: Alias for the filter.
40
+ :return: Decorator
41
+ :rtype: callable
42
+ """
43
+
44
+ def wrapper(cls):
45
+ cls._alias = alias # pylint: disable=protected-access
46
+ return cls
47
+
48
+ return wrapper
@@ -0,0 +1,142 @@
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 collections.abc import Mapping
7
+ import logging
8
+ from typing import overload
9
+ from ._defaultfilters import TimeWindowFilter, TargetingFilter
10
+ from ._featurefilters import FeatureFilter
11
+ from .._featuremanager import (
12
+ PROVIDED_FEATURE_FILTERS,
13
+ FEATURE_FILTER_NAME,
14
+ REQUIREMENT_TYPE_ALL,
15
+ FEATURE_MANAGEMENT_KEY,
16
+ _get_feature_flag,
17
+ _list_feature_flag_names,
18
+ )
19
+ from .._models import EvaluationEvent, TargetingContext
20
+
21
+
22
+ class FeatureManager:
23
+ """
24
+ Feature Manager that determines if a feature flag is enabled for the given context.
25
+
26
+ :param Mapping configuration: Configuration object.
27
+ :keyword list[FeatureFilter] feature_filters: Custom filters to be used for evaluating feature flags.
28
+ """
29
+
30
+ def __init__(self, configuration, **kwargs):
31
+ self._filters = {}
32
+ if configuration is None or not isinstance(configuration, Mapping):
33
+ raise AttributeError("Configuration must be a non-empty dictionary")
34
+ self._configuration = configuration
35
+ self._cache = {}
36
+ self._copy = configuration.get(FEATURE_MANAGEMENT_KEY)
37
+ filters = [TimeWindowFilter(), TargetingFilter()] + kwargs.pop(PROVIDED_FEATURE_FILTERS, [])
38
+
39
+ for feature_filter in filters:
40
+ if not isinstance(feature_filter, FeatureFilter):
41
+ raise ValueError("Custom filter must be a subclass of FeatureFilter")
42
+ self._filters[feature_filter.name] = feature_filter
43
+
44
+ def _build_targeting_context(self, args):
45
+ """
46
+ Builds a TargetingContext, either returns a provided context, takes the provided user_id to make a context, or
47
+ returns an empty context.
48
+
49
+ :param args: Arguments to build the TargetingContext.
50
+ :return: TargetingContext
51
+ """
52
+ if len(args) == 1 and isinstance(args[0], str):
53
+ return TargetingContext(user_id=args[0], groups=[])
54
+ if len(args) == 1 and isinstance(args[0], TargetingContext):
55
+ return args[0]
56
+ return TargetingContext()
57
+
58
+ @overload
59
+ async def is_enabled(self, feature_flag_id, user_id, **kwargs):
60
+ """
61
+ Determine if the feature flag is enabled for the given context.
62
+
63
+ :param str feature_flag_id: Name of the feature flag.
64
+ :param str user_id: User identifier.
65
+ :return: True if the feature flag is enabled for the given context.
66
+ :rtype: bool
67
+ """
68
+
69
+ async def is_enabled(self, feature_flag_id, *args, **kwargs):
70
+ """
71
+ Determine if the feature flag is enabled for the given context.
72
+
73
+ :param str feature_flag_id: Name of the feature flag.
74
+ :return: True if the feature flag is enabled for the given context.
75
+ :rtype: bool
76
+ """
77
+ targeting_context = self._build_targeting_context(args)
78
+ return (await self._check_feature(feature_flag_id, targeting_context, **kwargs)).enabled
79
+
80
+ async def _check_feature_filters(self, feature_flag, targeting_context, **kwargs):
81
+ feature_conditions = feature_flag.conditions
82
+ feature_filters = feature_conditions.client_filters
83
+ evaluation_event = EvaluationEvent(enabled=False)
84
+
85
+ if len(feature_filters) == 0:
86
+ # Feature flags without any filters return evaluate
87
+ evaluation_event.enabled = True
88
+ else:
89
+ # The assumed value is no filters is based on the requirement type.
90
+ # Requirement type Any assumes false until proven true, All assumes true until proven false
91
+ evaluation_event.enabled = feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL
92
+
93
+ for feature_filter in feature_filters:
94
+ filter_name = feature_filter[FEATURE_FILTER_NAME]
95
+ kwargs["user"] = targeting_context.user_id
96
+ kwargs["groups"] = targeting_context.groups
97
+ if filter_name not in self._filters:
98
+ raise ValueError(f"Feature flag {feature_flag.name} has unknown filter {filter_name}")
99
+ if feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL:
100
+ if not await self._filters[filter_name].evaluate(feature_filter, **kwargs):
101
+ evaluation_event.enabled = False
102
+ break
103
+ else:
104
+ if await self._filters[filter_name].evaluate(feature_filter, **kwargs):
105
+ evaluation_event.enabled = True
106
+ break
107
+ return evaluation_event
108
+
109
+ async def _check_feature(self, feature_flag_id, targeting_context, **kwargs):
110
+ """
111
+ Determine if the feature flag is enabled for the given context.
112
+
113
+ :param str feature_flag_id: Name of the feature flag.
114
+ :return: True if the feature flag is enabled for the given context.
115
+ :rtype: bool
116
+ """
117
+ if self._copy is not self._configuration.get(FEATURE_MANAGEMENT_KEY):
118
+ self._cache = {}
119
+ self._copy = self._configuration.get(FEATURE_MANAGEMENT_KEY)
120
+
121
+ if not self._cache.get(feature_flag_id):
122
+ feature_flag = _get_feature_flag(self._configuration, feature_flag_id)
123
+ self._cache[feature_flag_id] = feature_flag
124
+ else:
125
+ feature_flag = self._cache.get(feature_flag_id)
126
+
127
+ if not feature_flag:
128
+ logging.warning("Feature flag %s not found", feature_flag_id)
129
+ # Unknown feature flags are disabled by default
130
+ return EvaluationEvent(enabled=False)
131
+
132
+ if not feature_flag.enabled:
133
+ # Feature flags that are disabled are always disabled
134
+ return EvaluationEvent(enabled=False)
135
+
136
+ return await self._check_feature_filters(feature_flag, targeting_context, **kwargs)
137
+
138
+ def list_feature_flag_names(self):
139
+ """
140
+ List of all feature flag names.
141
+ """
142
+ return _list_feature_flag_names(self._configuration)
File without changes