Skip to content

Commit 63755d2

Browse files
authored
Async Variants (#22)
* Async + Add Variants to public API * changes from pylint update * Update test_feature_variants_async.py
1 parent 0fa9458 commit 63755d2

File tree

3 files changed

+386
-14
lines changed

3 files changed

+386
-14
lines changed

featuremanagement/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
from ._featuremanager import FeatureManager
77
from ._featurefilters import FeatureFilter
88
from ._defaultfilters import TimeWindowFilter, TargetingFilter
9+
from ._models._variant import Variant
910

1011
from ._version import VERSION
1112

1213
__version__ = VERSION
13-
__all__ = ["FeatureManager", "TimeWindowFilter", "TargetingFilter", "FeatureFilter"]
14+
__all__ = ["FeatureManager", "TimeWindowFilter", "TargetingFilter", "FeatureFilter", "Variant"]

featuremanagement/aio/_featuremanager.py

Lines changed: 140 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# -------------------------------------------------------------------------
66
from collections.abc import Mapping
77
import logging
8+
import hashlib
89
from ._defaultfilters import TimeWindowFilter, TargetingFilter
910
from ._featurefilters import FeatureFilter
1011
from .._featuremanager import (
@@ -15,6 +16,7 @@
1516
_get_feature_flag,
1617
_list_feature_flag_names,
1718
)
19+
from .._models._variant import Variant
1820

1921

2022
class FeatureManager:
@@ -42,6 +44,80 @@ def __init__(self, configuration, **kwargs):
4244
raise ValueError("Custom filter must be a subclass of FeatureFilter")
4345
self._filters[feature_filter.name] = feature_filter
4446

47+
@staticmethod
48+
def _check_default_disabled_variant(feature_flag):
49+
if not feature_flag.allocation:
50+
return False
51+
return FeatureManager._check_variant_override(
52+
feature_flag.variants, feature_flag.allocation.default_when_disabled, False
53+
)
54+
55+
@staticmethod
56+
def _check_default_enabled_variant(feature_flag):
57+
if not feature_flag.allocation:
58+
return True
59+
return FeatureManager._check_variant_override(
60+
feature_flag.variants, feature_flag.allocation.default_when_enabled, True
61+
)
62+
63+
@staticmethod
64+
def _check_variant_override(variants, default_variant_name, status):
65+
if not variants or not default_variant_name:
66+
return status
67+
for variant in variants:
68+
if variant.name == default_variant_name:
69+
if variant.status_override == "Enabled":
70+
return True
71+
if variant.status_override == "Disabled":
72+
return False
73+
return status
74+
75+
@staticmethod
76+
def _is_targeted(context_id):
77+
"""Determine if the user is targeted for the given context"""
78+
hashed_context_id = hashlib.sha256(context_id.encode()).digest()
79+
context_marker = int.from_bytes(hashed_context_id[:4], byteorder="little", signed=False)
80+
81+
return (context_marker / (2**32 - 1)) * 100
82+
83+
def _assign_variant(self, feature_flag, **kwargs):
84+
if not feature_flag.variants or not feature_flag.allocation:
85+
return None
86+
if feature_flag.allocation.user:
87+
user = kwargs.get("user")
88+
if user:
89+
for user_allocation in feature_flag.allocation.user:
90+
if user in user_allocation.users:
91+
return user_allocation.variant
92+
if feature_flag.allocation.group:
93+
groups = kwargs.get("groups")
94+
if groups:
95+
for group_allocation in feature_flag.allocation.group:
96+
for group in groups:
97+
if group in group_allocation.groups:
98+
return group_allocation.variant
99+
if feature_flag.allocation.percentile:
100+
user = kwargs.get("user", "")
101+
context_id = user + "\n" + feature_flag.allocation.seed
102+
box = self._is_targeted(context_id)
103+
for percentile_allocation in feature_flag.allocation.percentile:
104+
if box == 100 and percentile_allocation.percentile_to == 100:
105+
return percentile_allocation.variant
106+
if percentile_allocation.percentile_from <= box < percentile_allocation.percentile_to:
107+
return percentile_allocation.variant
108+
return None
109+
110+
def _variant_name_to_variant(self, feature_flag, variant_name):
111+
if not feature_flag.variants:
112+
return None
113+
for variant_reference in feature_flag.variants:
114+
if variant_reference.name == variant_name:
115+
configuration = variant_reference.configuration_value
116+
if not configuration:
117+
configuration = self._configuration.get(variant_reference.configuration_reference)
118+
return Variant(variant_reference.name, configuration)
119+
return None
120+
45121
async def is_enabled(self, feature_flag_id, **kwargs):
46122
"""
47123
Determine if the feature flag is enabled for the given context
@@ -51,6 +127,29 @@ async def is_enabled(self, feature_flag_id, **kwargs):
51127
:return: True if the feature flag is enabled for the given context
52128
:rtype: bool
53129
"""
130+
return (await self._check_feature(feature_flag_id, **kwargs))["enabled"]
131+
132+
async def get_variant(self, feature_flag_id, **kwargs):
133+
"""
134+
Determine the variant for the given context
135+
136+
:param str feature_flag_id: Name of the feature flag
137+
:paramtype feature_flag_id: str
138+
:return: Name of the variant
139+
:rtype: str
140+
"""
141+
return (await self._check_feature(feature_flag_id, **kwargs))["variant"]
142+
143+
async def _check_feature(self, feature_flag_id, **kwargs):
144+
"""
145+
Determine if the feature flag is enabled for the given context
146+
147+
:param str feature_flag_id: Name of the feature flag
148+
:paramtype feature_flag_id: str
149+
:return: True if the feature flag is enabled for the given context
150+
:rtype: bool
151+
"""
152+
result = {"enabled": None, "variant": None}
54153
if self._copy is not self._configuration.get(FEATURE_MANAGEMENT_KEY):
55154
self._cache = {}
56155
self._copy = self._configuration.get(FEATURE_MANAGEMENT_KEY)
@@ -64,32 +163,60 @@ async def is_enabled(self, feature_flag_id, **kwargs):
64163
if not feature_flag:
65164
logging.warning("Feature flag %s not found", feature_flag_id)
66165
# Unknown feature flags are disabled by default
67-
return False
166+
return result
68167

69168
if not feature_flag.enabled:
70169
# Feature flags that are disabled are always disabled
71-
return False
170+
result["enabled"] = FeatureManager._check_default_disabled_variant(feature_flag)
171+
if feature_flag.allocation:
172+
variant_name = feature_flag.allocation.default_when_disabled
173+
result["variant"] = self._variant_name_to_variant(feature_flag, variant_name)
174+
return result
72175

73176
feature_conditions = feature_flag.conditions
74177
feature_filters = feature_conditions.client_filters
75178

76179
if len(feature_filters) == 0:
77180
# Feature flags without any filters return evaluate
78-
return True
181+
result["enabled"] = True
182+
else:
183+
# The assumed value is no filters is based on the requirement type.
184+
# Requirement type Any assumes false until proven true, All assumes true until proven false
185+
result["enabled"] = feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL
79186

80187
for feature_filter in feature_filters:
81188
filter_name = feature_filter[FEATURE_FILTER_NAME]
82-
if filter_name in self._filters:
83-
if feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL:
84-
if not await self._filters[filter_name].evaluate(feature_filter, **kwargs):
85-
return False
86-
else:
87-
if await self._filters[filter_name].evaluate(feature_filter, **kwargs):
88-
return True
89-
else:
189+
if filter_name not in self._filters:
90190
raise ValueError(f"Feature flag {feature_flag_id} has unknown filter {filter_name}")
91-
# If this is reached, and true, default return value is true, else false
92-
return feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL
191+
if feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL:
192+
if not await self._filters[filter_name].evaluate(feature_filter, **kwargs):
193+
result["enabled"] = False
194+
break
195+
else:
196+
if await self._filters[filter_name].evaluate(feature_filter, **kwargs):
197+
result["enabled"] = True
198+
break
199+
200+
if feature_flag.allocation and feature_flag.variants:
201+
variant_name = self._assign_variant(feature_flag, **kwargs)
202+
if variant_name:
203+
result["enabled"] = FeatureManager._check_variant_override(
204+
feature_flag.variants, variant_name, result["enabled"]
205+
)
206+
result["variant"] = self._variant_name_to_variant(feature_flag, variant_name)
207+
return result
208+
209+
variant_name = None
210+
if result["enabled"]:
211+
result["enabled"] = FeatureManager._check_default_enabled_variant(feature_flag)
212+
if feature_flag.allocation:
213+
variant_name = feature_flag.allocation.default_when_enabled
214+
else:
215+
result["enabled"] = FeatureManager._check_default_disabled_variant(feature_flag)
216+
if feature_flag.allocation:
217+
variant_name = feature_flag.allocation.default_when_disabled
218+
result["variant"] = self._variant_name_to_variant(feature_flag, variant_name)
219+
return result
93220

94221
def list_feature_flag_names(self):
95222
"""

0 commit comments

Comments
 (0)