Skip to content

Commit 4a7a64b

Browse files
mrm9084rossgrambo
andauthored
Added Accessor (#55)
* Added Accessor with comments * comment and format change * fixing log * fixing log length * async version with tests * fix test and pylint issue * Update featuremanagement/aio/_featuremanager.py Co-authored-by: Ross Grambo <rossgrambo@microsoft.com> * Update _featuremanager.py --------- Co-authored-by: Ross Grambo <rossgrambo@microsoft.com>
1 parent 8dc9b0c commit 4a7a64b

7 files changed

+198
-10
lines changed

featuremanagement/_featuremanager.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
# Licensed under the MIT License. See License.txt in the project root for
44
# license information.
55
# -------------------------------------------------------------------------
6-
from typing import cast, overload, Any, Optional, Dict, Mapping, List
6+
import logging
7+
from typing import cast, overload, Any, Optional, Dict, Mapping, List, Tuple
78
from ._defaultfilters import TimeWindowFilter, TargetingFilter
89
from ._featurefilters import FeatureFilter
910
from ._models import EvaluationEvent, Variant, TargetingContext
@@ -14,6 +15,8 @@
1415
FEATURE_FILTER_NAME,
1516
)
1617

18+
logger = logging.getLogger(__name__)
19+
1720

1821
class FeatureManager(FeatureManagerBase):
1922
"""
@@ -23,6 +26,8 @@ class FeatureManager(FeatureManagerBase):
2326
:keyword list[FeatureFilter] feature_filters: Custom filters to be used for evaluating feature flags.
2427
:keyword Callable[EvaluationEvent] on_feature_evaluated: Callback function to be called when a feature flag is
2528
evaluated.
29+
:keyword Callable[[], TargetingContext] targeting_context_accessor: Callback function to get the current targeting
30+
context if one isn't provided.
2631
"""
2732

2833
def __init__(self, configuration: Mapping[str, Any], **kwargs: Any):
@@ -56,7 +61,7 @@ def is_enabled(self, feature_flag_id: str, *args: Any, **kwargs: Any) -> bool:
5661
:return: True if the feature flag is enabled for the given context.
5762
:rtype: bool
5863
"""
59-
targeting_context = self._build_targeting_context(args)
64+
targeting_context: TargetingContext = self._build_targeting_context(args)
6065

6166
result = self._check_feature(feature_flag_id, targeting_context, **kwargs)
6267
if (
@@ -89,7 +94,7 @@ def get_variant(self, feature_flag_id: str, *args: Any, **kwargs: Any) -> Option
8994
:return: Variant instance.
9095
:rtype: Variant
9196
"""
92-
targeting_context = self._build_targeting_context(args)
97+
targeting_context: TargetingContext = self._build_targeting_context(args)
9398

9499
result = self._check_feature(feature_flag_id, targeting_context, **kwargs)
95100
if (
@@ -102,6 +107,21 @@ def get_variant(self, feature_flag_id: str, *args: Any, **kwargs: Any) -> Option
102107
self._on_feature_evaluated(result)
103108
return result.variant
104109

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+
105125
def _check_feature_filters(
106126
self, evaluation_event: EvaluationEvent, targeting_context: TargetingContext, **kwargs: Any
107127
) -> None:

featuremanagement/_featuremanagerbase.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import hashlib
77
import logging
88
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
1010
from ._models import FeatureFlag, Variant, VariantAssignmentReason, TargetingContext, EvaluationEvent, VariantReference
1111

1212

@@ -21,6 +21,9 @@
2121
FEATURE_FILTER_PARAMETERS = "parameters"
2222

2323

24+
logger = logging.getLogger(__name__)
25+
26+
2427
def _get_feature_flag(configuration: Mapping[str, Any], feature_flag_name: str) -> Optional[FeatureFlag]:
2528
"""
2629
Gets the FeatureFlag json from the configuration, if it exists it gets converted to a FeatureFlag object.
@@ -77,6 +80,9 @@ def __init__(self, configuration: Mapping[str, Any], **kwargs: Any):
7780
self._cache: Dict[str, Optional[FeatureFlag]] = {}
7881
self._copy = configuration.get(FEATURE_MANAGEMENT_KEY)
7982
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+
)
8086

8187
@staticmethod
8288
def _assign_default_disabled_variant(evaluation_event: EvaluationEvent) -> None:
@@ -218,7 +224,7 @@ def _variant_name_to_variant(self, feature_flag: FeatureFlag, variant_name: Opti
218224
return Variant(variant_reference.name, variant_reference.configuration_value)
219225
return None
220226

221-
def _build_targeting_context(self, args: Tuple[Any]) -> TargetingContext:
227+
def _build_targeting_context(self, args: Tuple[Any]) -> Optional[TargetingContext]:
222228
"""
223229
Builds a TargetingContext, either returns a provided context, takes the provided user_id to make a context, or
224230
returns an empty context.
@@ -229,10 +235,12 @@ def _build_targeting_context(self, args: Tuple[Any]) -> TargetingContext:
229235
if len(args) == 1:
230236
arg = args[0]
231237
if isinstance(arg, str):
238+
# If the user_id is provided, return a TargetingContext with the user_id
232239
return TargetingContext(user_id=arg, groups=[])
233240
if isinstance(arg, TargetingContext):
241+
# If a TargetingContext is provided, return it
234242
return arg
235-
return TargetingContext()
243+
return None
236244

237245
def _assign_allocation(self, evaluation_event: EvaluationEvent, targeting_context: TargetingContext) -> None:
238246
feature_flag = evaluation_event.feature
@@ -271,7 +279,7 @@ def _check_feature_base(self, feature_flag_id: str) -> Tuple[EvaluationEvent, bo
271279

272280
evaluation_event = EvaluationEvent(feature_flag)
273281
if not feature_flag:
274-
logging.warning("Feature flag %s not found", feature_flag_id)
282+
logger.warning("Feature flag %s not found", feature_flag_id)
275283
# Unknown feature flags are disabled by default
276284
return evaluation_event, True
277285

featuremanagement/aio/_featuremanager.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
# license information.
55
# -------------------------------------------------------------------------
66
import inspect
7-
from typing import cast, overload, Any, Optional, Dict, Mapping, List
7+
import logging
8+
from typing import cast, overload, Any, Optional, Dict, Mapping, List, Tuple
89
from ._defaultfilters import TimeWindowFilter, TargetingFilter
910
from ._featurefilters import FeatureFilter
1011
from .._models import EvaluationEvent, Variant, TargetingContext
@@ -15,6 +16,8 @@
1516
FEATURE_FILTER_NAME,
1617
)
1718

19+
logger = logging.getLogger(__name__)
20+
1821

1922
class FeatureManager(FeatureManagerBase):
2023
"""
@@ -24,6 +27,8 @@ class FeatureManager(FeatureManagerBase):
2427
:keyword list[FeatureFilter] feature_filters: Custom filters to be used for evaluating feature flags.
2528
:keyword Callable[EvaluationEvent] on_feature_evaluated: Callback function to be called when a feature flag is
2629
evaluated.
30+
:keyword Callable[[], TargetingContext] targeting_context_accessor: Callback function to get the current targeting
31+
context if one isn't provided.
2732
"""
2833

2934
def __init__(self, configuration: Mapping[str, Any], **kwargs: Any):
@@ -57,7 +62,7 @@ async def is_enabled(self, feature_flag_id: str, *args: Any, **kwargs: Any) -> b
5762
:return: True if the feature flag is enabled for the given context.
5863
:rtype: bool
5964
"""
60-
targeting_context = self._build_targeting_context(args)
65+
targeting_context: TargetingContext = await self._build_targeting_context_async(args)
6166

6267
result = await self._check_feature(feature_flag_id, targeting_context, **kwargs)
6368
if (
@@ -93,7 +98,7 @@ async def get_variant(self, feature_flag_id: str, *args: Any, **kwargs: Any) ->
9398
:return: Variant instance.
9499
:rtype: Variant
95100
"""
96-
targeting_context = self._build_targeting_context(args)
101+
targeting_context: TargetingContext = await self._build_targeting_context_async(args)
97102

98103
result = await self._check_feature(feature_flag_id, targeting_context, **kwargs)
99104
if (
@@ -109,6 +114,25 @@ async def get_variant(self, feature_flag_id: str, *args: Any, **kwargs: Any) ->
109114
self._on_feature_evaluated(result)
110115
return result.variant
111116

117+
async def _build_targeting_context_async(self, args: Tuple[Any]) -> TargetingContext:
118+
targeting_context = super()._build_targeting_context(args)
119+
if targeting_context:
120+
return targeting_context
121+
if not targeting_context and self._targeting_context_accessor and callable(self._targeting_context_accessor):
122+
123+
if inspect.iscoroutinefunction(self._targeting_context_accessor):
124+
# If a targeting_context_accessor is provided, return the TargetingContext from it
125+
targeting_context = await self._targeting_context_accessor()
126+
else:
127+
targeting_context = self._targeting_context_accessor()
128+
if targeting_context and isinstance(targeting_context, TargetingContext):
129+
return targeting_context
130+
logger.warning(
131+
"targeting_context_accessor did not return a TargetingContext. Received type %s.",
132+
type(targeting_context),
133+
)
134+
return TargetingContext()
135+
112136
async def _check_feature_filters(
113137
self, evaluation_event: EvaluationEvent, targeting_context: TargetingContext, **kwargs: Any
114138
) -> None:
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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+
import json
8+
import os
9+
import sys
10+
from random_filter import RandomFilter
11+
from featuremanagement import FeatureManager, TargetingContext
12+
13+
14+
script_directory = os.path.dirname(os.path.abspath(sys.argv[0]))
15+
16+
with open(script_directory + "/formatted_feature_flags.json", "r", encoding="utf-8") as f:
17+
feature_flags = json.load(f)
18+
19+
USER_ID = "Adam"
20+
21+
22+
def my_targeting_accessor() -> TargetingContext:
23+
return TargetingContext(user_id=USER_ID)
24+
25+
26+
feature_manager = FeatureManager(
27+
feature_flags, feature_filters=[RandomFilter()], targeting_context_accessor=my_targeting_accessor
28+
)
29+
30+
print(feature_manager.is_enabled("TestVariants"))
31+
print(feature_manager.get_variant("TestVariants").configuration)
32+
33+
USER_ID = "Ellie"
34+
35+
print(feature_manager.is_enabled("TestVariants"))
36+
print(feature_manager.get_variant("TestVariants").configuration)

samples/formatted_feature_flags.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@
218218
},
219219
{
220220
"name": "False_Override",
221+
"configuration_value": "The Variant False_Override overrides to True",
221222
"status_override": "True"
222223
}
223224
]

tests/test_default_feature_flags.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,3 +261,52 @@ def test_feature_manager_requirement_type(self):
261261
# The second TimeWindow filter failed
262262
assert not feature_manager.is_enabled("Beta")
263263
assert feature_manager.is_enabled("Gamma")
264+
265+
def test_feature_manager_with_targeting_accessor(self):
266+
feature_flags = {
267+
"feature_management": {
268+
"feature_flags": [
269+
{
270+
"id": "Target",
271+
"enabled": "true",
272+
"conditions": {
273+
"client_filters": [
274+
{
275+
"name": "Microsoft.Targeting",
276+
"parameters": {
277+
"Audience": {
278+
"Users": ["Adam"],
279+
"Groups": [{"Name": "Stage1", "RolloutPercentage": 100}],
280+
"DefaultRolloutPercentage": 50,
281+
"Exclusion": {"Users": [], "Groups": []},
282+
}
283+
},
284+
}
285+
]
286+
},
287+
},
288+
]
289+
}
290+
}
291+
292+
user_id = "Adam"
293+
group_id = None
294+
295+
def my_targeting_accessor() -> TargetingContext:
296+
if group_id:
297+
return TargetingContext(user_id=user_id, groups=[group_id])
298+
return TargetingContext(user_id=user_id)
299+
300+
feature_manager = FeatureManager(feature_flags, targeting_context_accessor=my_targeting_accessor)
301+
assert feature_manager is not None
302+
# Adam is in the user audience
303+
assert feature_manager.is_enabled("Target")
304+
# Belle is not part of the 50% or default 50% of users
305+
user_id = "Belle"
306+
assert not feature_manager.is_enabled("Target")
307+
# Belle is enabled because all of Stage 1 is enabled
308+
group_id = "Stage1"
309+
assert feature_manager.is_enabled("Target")
310+
# Belle is not enabled because he is not in Stage 2, group isn't looked at when user is targeted
311+
group_id = "Stage2"
312+
assert not feature_manager.is_enabled("Target")

tests/test_default_feature_flags_async.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,3 +266,53 @@ async def test_feature_manager_requirement_type(self):
266266
# The second TimeWindow filter failed
267267
assert not await feature_manager.is_enabled("Beta")
268268
assert await feature_manager.is_enabled("Gamma")
269+
270+
@pytest.mark.asyncio
271+
async def test_feature_manager_with_targeting_accessor(self):
272+
feature_flags = {
273+
"feature_management": {
274+
"feature_flags": [
275+
{
276+
"id": "Target",
277+
"enabled": "true",
278+
"conditions": {
279+
"client_filters": [
280+
{
281+
"name": "Microsoft.Targeting",
282+
"parameters": {
283+
"Audience": {
284+
"Users": ["Adam"],
285+
"Groups": [{"Name": "Stage1", "RolloutPercentage": 100}],
286+
"DefaultRolloutPercentage": 50,
287+
"Exclusion": {"Users": [], "Groups": []},
288+
}
289+
},
290+
}
291+
]
292+
},
293+
},
294+
]
295+
}
296+
}
297+
298+
user_id = "Adam"
299+
group_id = None
300+
301+
def my_targeting_accessor() -> TargetingContext:
302+
if group_id:
303+
return TargetingContext(user_id=user_id, groups=[group_id])
304+
return TargetingContext(user_id=user_id)
305+
306+
feature_manager = FeatureManager(feature_flags, targeting_context_accessor=my_targeting_accessor)
307+
assert feature_manager is not None
308+
# Adam is in the user audience
309+
assert await feature_manager.is_enabled("Target")
310+
# Belle is not part of the 50% or default 50% of users
311+
user_id = "Belle"
312+
assert not await feature_manager.is_enabled("Target")
313+
# Belle is enabled because all of Stage 1 is enabled
314+
group_id = "Stage1"
315+
assert await feature_manager.is_enabled("Target")
316+
# Belle is not enabled because he is not in Stage 2, group isn't looked at when user is targeted
317+
group_id = "Stage2"
318+
assert not await feature_manager.is_enabled("Target")

0 commit comments

Comments
 (0)