Skip to content

Commit 7260990

Browse files
authored
Revert "Removing non-b1 features" (#32)
* Revert "Removing non-b1 features (#25)" This reverts commit e6f53c9. * Updating beta version, fixing readme
1 parent d4acf63 commit 7260990

17 files changed

+1254
-10
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Release History
22

3+
## 2.0.0b1 (Unreleased)
4+
5+
* Adds support for Feature Variants.
6+
37
## 1.0.0 (06/26/2024)
48

59
Updated version to 1.0.0.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Feature management provides a way to develop and expose application functionalit
1313
## Examples
1414

1515
* [Python Application](https://github.com/microsoft/FeatureManagement-Python/blob/main/samples/feature_flag_sample.py)
16+
* [Python Application with Feature Variants](https://github.com/microsoft/FeatureManagement-Python/blob/main/samples/feature_variant_sample.py)
1617
* [Python Application with Azure App Configuration](https://github.com/microsoft/FeatureManagement-Python/blob/main/samples/feature_flag_with_azure_app_configuration_sample.py)
1718
* [Django Application](https://github.com/Azure/AppConfiguration/tree/main/examples/Python/python-django-webapp-sample)
1819
* [Flask Application](https://github.com/Azure/AppConfiguration/tree/main/examples/Python/python-flask-webapp-sample)

docs/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
project = "FeatureManagement"
1111
copyright = "2024, Microsoft"
1212
author = "Microsoft"
13-
release = "1.0.0"
13+
release = "2.0.0b1"
1414

1515
# -- General configuration ---------------------------------------------------
1616

featuremanagement/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from ._featuremanager import FeatureManager
77
from ._featurefilters import FeatureFilter
88
from ._defaultfilters import TimeWindowFilter, TargetingFilter
9-
from ._models import TargetingContext
9+
from ._models import FeatureFlag, Variant, TargetingContext
1010

1111
from ._version import VERSION
1212

@@ -16,5 +16,7 @@
1616
"TimeWindowFilter",
1717
"TargetingFilter",
1818
"FeatureFilter",
19+
"FeatureFlag",
20+
"Variant",
1921
"TargetingContext",
2022
]

featuremanagement/_featuremanager.py

Lines changed: 165 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44
# license information.
55
# -------------------------------------------------------------------------
66
import logging
7+
import hashlib
78
from collections.abc import Mapping
89
from typing import overload
910
from ._defaultfilters import TimeWindowFilter, TargetingFilter
1011
from ._featurefilters import FeatureFilter
11-
from ._models import FeatureFlag, EvaluationEvent, TargetingContext
12+
from ._models import FeatureFlag, Variant, EvaluationEvent, TargetingContext
1213

1314

1415
FEATURE_MANAGEMENT_KEY = "feature_management"
@@ -88,6 +89,111 @@ def __init__(self, configuration, **kwargs):
8889
raise ValueError("Custom filter must be a subclass of FeatureFilter")
8990
self._filters[feature_filter.name] = feature_filter
9091

92+
@staticmethod
93+
def _check_default_disabled_variant(feature_flag):
94+
"""
95+
A method called when the feature flag is disabled, to determine what the default variant should be. If there is
96+
no allocation, then None is set as the value of the variant in the EvaluationEvent.
97+
98+
:param FeatureFlag feature_flag: Feature flag object.
99+
:return: EvaluationEvent
100+
"""
101+
if not feature_flag.allocation:
102+
return EvaluationEvent(enabled=False)
103+
return FeatureManager._check_variant_override(
104+
feature_flag.variants, feature_flag.allocation.default_when_disabled, False
105+
)
106+
107+
@staticmethod
108+
def _check_default_enabled_variant(feature_flag):
109+
"""
110+
A method called when the feature flag is enabled, to determine what the default variant should be. If there is
111+
no allocation, then None is set as the value of the variant in the EvaluationEvent.
112+
113+
:param FeatureFlag feature_flag: Feature flag object.
114+
:return: EvaluationEvent
115+
"""
116+
if not feature_flag.allocation:
117+
return EvaluationEvent(enabled=True)
118+
return FeatureManager._check_variant_override(
119+
feature_flag.variants, feature_flag.allocation.default_when_enabled, True
120+
)
121+
122+
@staticmethod
123+
def _check_variant_override(variants, default_variant_name, status):
124+
"""
125+
A method to check if a variant is overridden to be enabled or disabled by the variant.
126+
127+
:param list[Variant] variants: List of variants.
128+
:param str default_variant_name: Name of the default variant.
129+
:param bool status: Status of the feature flag.
130+
:return: EvaluationEvent
131+
"""
132+
if not variants or not default_variant_name:
133+
return EvaluationEvent(enabled=status)
134+
for variant in variants:
135+
if variant.name == default_variant_name:
136+
if variant.status_override == "Enabled":
137+
return EvaluationEvent(enabled=True)
138+
if variant.status_override == "Disabled":
139+
return EvaluationEvent(enabled=False)
140+
return EvaluationEvent(enabled=status)
141+
142+
@staticmethod
143+
def _is_targeted(context_id):
144+
"""Determine if the user is targeted for the given context"""
145+
hashed_context_id = hashlib.sha256(context_id.encode()).digest()
146+
context_marker = int.from_bytes(hashed_context_id[:4], byteorder="little", signed=False)
147+
148+
return (context_marker / (2**32 - 1)) * 100
149+
150+
def _assign_variant(self, feature_flag, targeting_context):
151+
"""
152+
Assign a variant to the user based on the allocation.
153+
154+
:param FeatureFlag feature_flag: Feature flag object.
155+
:param TargetingContext targeting_context: Targeting context.
156+
:return: Variant name.
157+
"""
158+
if not feature_flag.variants or not feature_flag.allocation:
159+
return None
160+
if feature_flag.allocation.user and targeting_context.user_id:
161+
for user_allocation in feature_flag.allocation.user:
162+
if targeting_context.user_id in user_allocation.users:
163+
return user_allocation.variant
164+
if feature_flag.allocation.group and len(targeting_context.groups) > 0:
165+
for group_allocation in feature_flag.allocation.group:
166+
for group in targeting_context.groups:
167+
if group in group_allocation.groups:
168+
return group_allocation.variant
169+
if feature_flag.allocation.percentile:
170+
context_id = targeting_context.user_id + "\n" + feature_flag.allocation.seed
171+
box = self._is_targeted(context_id)
172+
for percentile_allocation in feature_flag.allocation.percentile:
173+
if box == 100 and percentile_allocation.percentile_to == 100:
174+
return percentile_allocation.variant
175+
if percentile_allocation.percentile_from <= box < percentile_allocation.percentile_to:
176+
return percentile_allocation.variant
177+
return None
178+
179+
def _variant_name_to_variant(self, feature_flag, variant_name):
180+
"""
181+
Get the variant object from the variant name.
182+
183+
:param FeatureFlag feature_flag: Feature flag object.
184+
:param str variant_name: Name of the variant.
185+
:return: Variant object.
186+
"""
187+
if not feature_flag.variants:
188+
return None
189+
for variant_reference in feature_flag.variants:
190+
if variant_reference.name == variant_name:
191+
configuration = variant_reference.configuration_value
192+
if not configuration:
193+
configuration = self._configuration.get(variant_reference.configuration_reference)
194+
return Variant(variant_reference.name, configuration)
195+
return None
196+
91197
def _build_targeting_context(self, args):
92198
"""
93199
Builds a TargetingContext, either returns a provided context, takes the provided user_id to make a context, or
@@ -126,6 +232,31 @@ def is_enabled(self, feature_flag_id, *args, **kwargs):
126232
result = self._check_feature(feature_flag_id, targeting_context, **kwargs)
127233
return result.enabled
128234

235+
@overload
236+
def get_variant(self, feature_flag_id, user_id, **kwargs):
237+
"""
238+
Determine the variant for the given context.
239+
240+
:param str feature_flag_id: Name of the feature flag.
241+
:param str user_id: User identifier.
242+
:return: return: Variant instance.
243+
:rtype: Variant
244+
"""
245+
246+
def get_variant(self, feature_flag_id, *args, **kwargs):
247+
"""
248+
Determine the variant for the given context.
249+
250+
:param str feature_flag_id: Name of the feature flag
251+
:keyword TargetingContext targeting_context: Targeting context.
252+
:return: Variant instance.
253+
:rtype: Variant
254+
"""
255+
targeting_context = self._build_targeting_context(args)
256+
257+
result = self._check_feature(feature_flag_id, targeting_context, **kwargs)
258+
return result.variant
259+
129260
def _check_feature_filters(self, feature_flag, targeting_context, **kwargs):
130261
feature_conditions = feature_flag.conditions
131262
feature_filters = feature_conditions.client_filters
@@ -154,6 +285,30 @@ def _check_feature_filters(self, feature_flag, targeting_context, **kwargs):
154285
break
155286
return evaluation_event
156287

288+
def _assign_allocation(self, feature_flag, evaluation_event, targeting_context):
289+
if feature_flag.allocation and feature_flag.variants:
290+
variant_name = self._assign_variant(feature_flag, targeting_context)
291+
if variant_name:
292+
evaluation_event.enabled = FeatureManager._check_variant_override(
293+
feature_flag.variants, variant_name, evaluation_event.enabled
294+
).enabled
295+
evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name)
296+
evaluation_event.feature = feature_flag
297+
return evaluation_event
298+
299+
variant_name = None
300+
if evaluation_event.enabled:
301+
evaluation_event = FeatureManager._check_default_enabled_variant(feature_flag)
302+
if feature_flag.allocation:
303+
variant_name = feature_flag.allocation.default_when_enabled
304+
else:
305+
evaluation_event = FeatureManager._check_default_disabled_variant(feature_flag)
306+
if feature_flag.allocation:
307+
variant_name = feature_flag.allocation.default_when_disabled
308+
evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name)
309+
evaluation_event.feature = feature_flag
310+
return evaluation_event
311+
157312
def _check_feature(self, feature_flag_id, targeting_context, **kwargs):
158313
"""
159314
Determine if the feature flag is enabled for the given context.
@@ -179,9 +334,16 @@ def _check_feature(self, feature_flag_id, targeting_context, **kwargs):
179334

180335
if not feature_flag.enabled:
181336
# Feature flags that are disabled are always disabled
182-
return EvaluationEvent(enabled=False)
337+
evaluation_event = FeatureManager._check_default_disabled_variant(feature_flag)
338+
if feature_flag.allocation:
339+
variant_name = feature_flag.allocation.default_when_disabled
340+
evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name)
341+
evaluation_event.feature = feature_flag
342+
return evaluation_event
343+
344+
evaluation_event = self._check_feature_filters(feature_flag, targeting_context, **kwargs)
183345

184-
return self._check_feature_filters(feature_flag, targeting_context, **kwargs)
346+
return self._assign_allocation(feature_flag, evaluation_event, targeting_context)
185347

186348
def list_feature_flag_names(self):
187349
"""

featuremanagement/_models/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
# license information.
55
# -------------------------------------------------------------------------
66
from ._feature_flag import FeatureFlag
7+
from ._variant import Variant
78
from ._evaluation_event import EvaluationEvent
89
from ._targeting_context import TargetingContext
910

1011
__path__ = __import__("pkgutil").extend_path(__path__, __name__) # type: ignore
1112

12-
__all__ = ["FeatureFlag", "EvaluationEvent", "TargetingContext"]
13+
__all__ = ["FeatureFlag", "Variant", "EvaluationEvent", "TargetingContext"]

0 commit comments

Comments
 (0)