Skip to content

Added Accessor #55

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Feb 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions featuremanagement/_featuremanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# -------------------------------------------------------------------------
from typing import cast, overload, Any, Optional, Dict, Mapping, List
import logging
from typing import cast, overload, Any, Optional, Dict, Mapping, List, Tuple
from ._defaultfilters import TimeWindowFilter, TargetingFilter
from ._featurefilters import FeatureFilter
from ._models import EvaluationEvent, Variant, TargetingContext
Expand All @@ -14,6 +15,8 @@
FEATURE_FILTER_NAME,
)

logger = logging.getLogger(__name__)


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

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

result = self._check_feature(feature_flag_id, targeting_context, **kwargs)
if (
Expand Down Expand Up @@ -89,7 +94,7 @@ def get_variant(self, feature_flag_id: str, *args: Any, **kwargs: Any) -> Option
:return: Variant instance.
:rtype: Variant
"""
targeting_context = self._build_targeting_context(args)
targeting_context: TargetingContext = self._build_targeting_context(args)

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

def _build_targeting_context(self, args: Tuple[Any]) -> TargetingContext:
targeting_context = super()._build_targeting_context(args)
if targeting_context:
return targeting_context
if not targeting_context and self._targeting_context_accessor and callable(self._targeting_context_accessor):
targeting_context = self._targeting_context_accessor()
if targeting_context and isinstance(targeting_context, TargetingContext):
return targeting_context
logger.warning(
"targeting_context_accessor did not return a TargetingContext. Received type %s.",
type(targeting_context),
)

return TargetingContext()

def _check_feature_filters(
self, evaluation_event: EvaluationEvent, targeting_context: TargetingContext, **kwargs: Any
) -> None:
Expand Down
16 changes: 12 additions & 4 deletions featuremanagement/_featuremanagerbase.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import hashlib
import logging
from abc import ABC
from typing import List, Optional, Dict, Tuple, Any, Mapping
from typing import List, Optional, Dict, Tuple, Any, Mapping, Callable
from ._models import FeatureFlag, Variant, VariantAssignmentReason, TargetingContext, EvaluationEvent, VariantReference


Expand All @@ -21,6 +21,9 @@
FEATURE_FILTER_PARAMETERS = "parameters"


logger = logging.getLogger(__name__)


def _get_feature_flag(configuration: Mapping[str, Any], feature_flag_name: str) -> Optional[FeatureFlag]:
"""
Gets the FeatureFlag json from the configuration, if it exists it gets converted to a FeatureFlag object.
Expand Down Expand Up @@ -77,6 +80,9 @@ def __init__(self, configuration: Mapping[str, Any], **kwargs: Any):
self._cache: Dict[str, Optional[FeatureFlag]] = {}
self._copy = configuration.get(FEATURE_MANAGEMENT_KEY)
self._on_feature_evaluated = kwargs.pop("on_feature_evaluated", None)
self._targeting_context_accessor: Optional[Callable[[], TargetingContext]] = kwargs.pop(
"targeting_context_accessor", None
)

@staticmethod
def _assign_default_disabled_variant(evaluation_event: EvaluationEvent) -> None:
Expand Down Expand Up @@ -218,7 +224,7 @@ def _variant_name_to_variant(self, feature_flag: FeatureFlag, variant_name: Opti
return Variant(variant_reference.name, variant_reference.configuration_value)
return None

def _build_targeting_context(self, args: Tuple[Any]) -> TargetingContext:
def _build_targeting_context(self, args: Tuple[Any]) -> Optional[TargetingContext]:
"""
Builds a TargetingContext, either returns a provided context, takes the provided user_id to make a context, or
returns an empty context.
Expand All @@ -229,10 +235,12 @@ def _build_targeting_context(self, args: Tuple[Any]) -> TargetingContext:
if len(args) == 1:
arg = args[0]
if isinstance(arg, str):
# If the user_id is provided, return a TargetingContext with the user_id
return TargetingContext(user_id=arg, groups=[])
if isinstance(arg, TargetingContext):
# If a TargetingContext is provided, return it
return arg
return TargetingContext()
return None

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

evaluation_event = EvaluationEvent(feature_flag)
if not feature_flag:
logging.warning("Feature flag %s not found", feature_flag_id)
logger.warning("Feature flag %s not found", feature_flag_id)
# Unknown feature flags are disabled by default
return evaluation_event, True

Expand Down
30 changes: 27 additions & 3 deletions featuremanagement/aio/_featuremanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
# license information.
# -------------------------------------------------------------------------
import inspect
from typing import cast, overload, Any, Optional, Dict, Mapping, List
import logging
from typing import cast, overload, Any, Optional, Dict, Mapping, List, Tuple
from ._defaultfilters import TimeWindowFilter, TargetingFilter
from ._featurefilters import FeatureFilter
from .._models import EvaluationEvent, Variant, TargetingContext
Expand All @@ -15,6 +16,8 @@
FEATURE_FILTER_NAME,
)

logger = logging.getLogger(__name__)


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

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

result = await self._check_feature(feature_flag_id, targeting_context, **kwargs)
if (
Expand Down Expand Up @@ -93,7 +98,7 @@ async def get_variant(self, feature_flag_id: str, *args: Any, **kwargs: Any) ->
:return: Variant instance.
:rtype: Variant
"""
targeting_context = self._build_targeting_context(args)
targeting_context: TargetingContext = await self._build_targeting_context_async(args)

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

async def _build_targeting_context_async(self, args: Tuple[Any]) -> TargetingContext:
targeting_context = super()._build_targeting_context(args)
if targeting_context:
return targeting_context
if not targeting_context and self._targeting_context_accessor and callable(self._targeting_context_accessor):

if inspect.iscoroutinefunction(self._targeting_context_accessor):
# If a targeting_context_accessor is provided, return the TargetingContext from it
targeting_context = await self._targeting_context_accessor()
else:
targeting_context = self._targeting_context_accessor()
if targeting_context and isinstance(targeting_context, TargetingContext):
return targeting_context
logger.warning(
"targeting_context_accessor did not return a TargetingContext. Received type %s.",
type(targeting_context),
)
return TargetingContext()

async def _check_feature_filters(
self, evaluation_event: EvaluationEvent, targeting_context: TargetingContext, **kwargs: Any
) -> None:
Expand Down
36 changes: 36 additions & 0 deletions samples/feature_variant_sample_with_targeting_accessor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# -------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# --------------------------------------------------------------------------

import json
import os
import sys
from random_filter import RandomFilter
from featuremanagement import FeatureManager, TargetingContext


script_directory = os.path.dirname(os.path.abspath(sys.argv[0]))

with open(script_directory + "/formatted_feature_flags.json", "r", encoding="utf-8") as f:
feature_flags = json.load(f)

USER_ID = "Adam"


def my_targeting_accessor() -> TargetingContext:
return TargetingContext(user_id=USER_ID)


feature_manager = FeatureManager(
feature_flags, feature_filters=[RandomFilter()], targeting_context_accessor=my_targeting_accessor
)

print(feature_manager.is_enabled("TestVariants"))
print(feature_manager.get_variant("TestVariants").configuration)

USER_ID = "Ellie"

print(feature_manager.is_enabled("TestVariants"))
print(feature_manager.get_variant("TestVariants").configuration)
1 change: 1 addition & 0 deletions samples/formatted_feature_flags.json
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@
},
{
"name": "False_Override",
"configuration_value": "The Variant False_Override overrides to True",
"status_override": "True"
}
]
Expand Down
49 changes: 49 additions & 0 deletions tests/test_default_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,52 @@ def test_feature_manager_requirement_type(self):
# The second TimeWindow filter failed
assert not feature_manager.is_enabled("Beta")
assert feature_manager.is_enabled("Gamma")

def test_feature_manager_with_targeting_accessor(self):
feature_flags = {
"feature_management": {
"feature_flags": [
{
"id": "Target",
"enabled": "true",
"conditions": {
"client_filters": [
{
"name": "Microsoft.Targeting",
"parameters": {
"Audience": {
"Users": ["Adam"],
"Groups": [{"Name": "Stage1", "RolloutPercentage": 100}],
"DefaultRolloutPercentage": 50,
"Exclusion": {"Users": [], "Groups": []},
}
},
}
]
},
},
]
}
}

user_id = "Adam"
group_id = None

def my_targeting_accessor() -> TargetingContext:
if group_id:
return TargetingContext(user_id=user_id, groups=[group_id])
return TargetingContext(user_id=user_id)

feature_manager = FeatureManager(feature_flags, targeting_context_accessor=my_targeting_accessor)
assert feature_manager is not None
# Adam is in the user audience
assert feature_manager.is_enabled("Target")
# Belle is not part of the 50% or default 50% of users
user_id = "Belle"
assert not feature_manager.is_enabled("Target")
# Belle is enabled because all of Stage 1 is enabled
group_id = "Stage1"
assert feature_manager.is_enabled("Target")
# Belle is not enabled because he is not in Stage 2, group isn't looked at when user is targeted
group_id = "Stage2"
assert not feature_manager.is_enabled("Target")
50 changes: 50 additions & 0 deletions tests/test_default_feature_flags_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,3 +266,53 @@ async def test_feature_manager_requirement_type(self):
# The second TimeWindow filter failed
assert not await feature_manager.is_enabled("Beta")
assert await feature_manager.is_enabled("Gamma")

@pytest.mark.asyncio
async def test_feature_manager_with_targeting_accessor(self):
feature_flags = {
"feature_management": {
"feature_flags": [
{
"id": "Target",
"enabled": "true",
"conditions": {
"client_filters": [
{
"name": "Microsoft.Targeting",
"parameters": {
"Audience": {
"Users": ["Adam"],
"Groups": [{"Name": "Stage1", "RolloutPercentage": 100}],
"DefaultRolloutPercentage": 50,
"Exclusion": {"Users": [], "Groups": []},
}
},
}
]
},
},
]
}
}

user_id = "Adam"
group_id = None

def my_targeting_accessor() -> TargetingContext:
if group_id:
return TargetingContext(user_id=user_id, groups=[group_id])
return TargetingContext(user_id=user_id)

feature_manager = FeatureManager(feature_flags, targeting_context_accessor=my_targeting_accessor)
assert feature_manager is not None
# Adam is in the user audience
assert await feature_manager.is_enabled("Target")
# Belle is not part of the 50% or default 50% of users
user_id = "Belle"
assert not await feature_manager.is_enabled("Target")
# Belle is enabled because all of Stage 1 is enabled
group_id = "Stage1"
assert await feature_manager.is_enabled("Target")
# Belle is not enabled because he is not in Stage 2, group isn't looked at when user is targeted
group_id = "Stage2"
assert not await feature_manager.is_enabled("Target")