From e3fd558fab8bac97581b01b776eda237e4359447 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Thu, 4 Apr 2024 13:35:56 -0700 Subject: [PATCH 01/35] telemetry support --- dev_requirements.txt | 2 + featuremanagement/__init__.py | 15 +- featuremanagement/_featuremanager.py | 167 +++++++++-------- featuremanagement/_models/__init__.py | 17 ++ .../_models/_evaluation_event.py | 23 +++ featuremanagement/_models/_feature_flag.py | 14 ++ featuremanagement/_models/_telemetry.py | 16 ++ .../_models/_variant_assignment_reason.py | 19 ++ .../_send_telemetry_appinsights.py | 41 +++++ featuremanagement/aio/_featuremanager.py | 173 ++++++++++-------- pyproject.toml | 3 + .../feature_variant_sample_with_telemetry.py | 30 +++ samples/formatted_feature_flags.json | 6 + setup.py | 3 + tests/test_send_telemetry_appinsights.py | 84 +++++++++ 15 files changed, 462 insertions(+), 151 deletions(-) create mode 100644 featuremanagement/_models/_evaluation_event.py create mode 100644 featuremanagement/_models/_telemetry.py create mode 100644 featuremanagement/_models/_variant_assignment_reason.py create mode 100644 featuremanagement/_send_telemetry_appinsights.py create mode 100644 samples/feature_variant_sample_with_telemetry.py create mode 100644 tests/test_send_telemetry_appinsights.py diff --git a/dev_requirements.txt b/dev_requirements.txt index 6c17d4b..b17aa9c 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -8,3 +8,5 @@ sphinx sphinx_rtd_theme myst_parser azure-appconfiguration-provider +azure-monitor-opentelemetry +azure-monitor-events-extension diff --git a/featuremanagement/__init__.py b/featuremanagement/__init__.py index 31dafd3..8e5bfc7 100644 --- a/featuremanagement/__init__.py +++ b/featuremanagement/__init__.py @@ -6,9 +6,20 @@ from ._featuremanager import FeatureManager from ._featurefilters import FeatureFilter from ._defaultfilters import TimeWindowFilter, TargetingFilter -from ._models._variant import Variant +from ._models import FeatureFlag, Variant, EvaluationEvent, VaraintAssignmentReason +from ._send_telemetry_appinsights import send_telemetry_appinsights from ._version import VERSION __version__ = VERSION -__all__ = ["FeatureManager", "TimeWindowFilter", "TargetingFilter", "FeatureFilter", "Variant"] +__all__ = [ + "FeatureManager", + "TimeWindowFilter", + "TargetingFilter", + "FeatureFilter", + "FeatureFlag", + "Variant", + "EvaluationEvent", + "VaraintAssignmentReason", + "send_telemetry_appinsights", +] diff --git a/featuremanagement/_featuremanager.py b/featuremanagement/_featuremanager.py index 2f82f98..b572c31 100644 --- a/featuremanagement/_featuremanager.py +++ b/featuremanagement/_featuremanager.py @@ -8,8 +8,7 @@ from collections.abc import Mapping from ._defaultfilters import TimeWindowFilter, TargetingFilter from ._featurefilters import FeatureFilter -from ._models._feature_flag import FeatureFlag -from ._models._variant import Variant +from ._models import FeatureFlag, Variant, EvaluationEvent, VaraintAssignmentReason FEATURE_MANAGEMENT_KEY = "feature_management" @@ -64,6 +63,8 @@ class FeatureManager: :type configuration: Mapping :keyword feature_filters: Custom filters to be used for evaluating feature flags :paramtype feature_filters: list[FeatureFilter] + :keyword telemetry: Telemetry callback function + :paramtype telemetry: Callable[EvaluationEvent] """ def __init__(self, configuration, **kwargs): @@ -73,6 +74,7 @@ def __init__(self, configuration, **kwargs): self._configuration = configuration self._cache = {} self._copy = configuration.get(FEATURE_MANAGEMENT_KEY) + self._telemetry = kwargs.get("telemetry", None) filters = [TimeWindowFilter(), TargetingFilter()] + kwargs.pop(PROVIDED_FEATURE_FILTERS, []) for feature_filter in filters: @@ -83,7 +85,7 @@ def __init__(self, configuration, **kwargs): @staticmethod def _check_default_disabled_variant(feature_flag): if not feature_flag.allocation: - return False + return EvaluationEvent(enabled=False) return FeatureManager._check_variant_override( feature_flag.variants, feature_flag.allocation.default_when_disabled, False ) @@ -91,7 +93,7 @@ def _check_default_disabled_variant(feature_flag): @staticmethod def _check_default_enabled_variant(feature_flag): if not feature_flag.allocation: - return True + return EvaluationEvent(enabled=True) return FeatureManager._check_variant_override( feature_flag.variants, feature_flag.allocation.default_when_enabled, True ) @@ -99,14 +101,14 @@ def _check_default_enabled_variant(feature_flag): @staticmethod def _check_variant_override(variants, default_variant_name, status): if not variants or not default_variant_name: - return status + return EvaluationEvent(enabled=status) for variant in variants: if variant.name == default_variant_name: if variant.status_override == "Enabled": - return True + return EvaluationEvent(enabled=True) if variant.status_override == "Disabled": - return False - return status + return EvaluationEvent(enabled=False) + return EvaluationEvent(enabled=status) @staticmethod def _is_targeted(context_id): @@ -117,31 +119,32 @@ def _is_targeted(context_id): return (context_marker / (2**32 - 1)) * 100 def _assign_variant(self, feature_flag, **kwargs): + user = kwargs.get("user", "") + groups = kwargs.get("groups", []) + evaluation_event = EvaluationEvent(feature_flag=feature_flag) if not feature_flag.variants or not feature_flag.allocation: - return None - if feature_flag.allocation.user: - user = kwargs.get("user") - if user: - for user_allocation in feature_flag.allocation.user: - if user in user_allocation.users: - return user_allocation.variant - if feature_flag.allocation.group: - groups = kwargs.get("groups") - if groups: - for group_allocation in feature_flag.allocation.group: - for group in groups: - if group in group_allocation.groups: - return group_allocation.variant + return None, evaluation_event + if feature_flag.allocation.user and user: + for user_allocation in feature_flag.allocation.user: + if user in user_allocation.users: + evaluation_event.reason = VaraintAssignmentReason.USER + return user_allocation.variant, evaluation_event + if feature_flag.allocation.group and len(groups): + for group_allocation in feature_flag.allocation.group: + for group in groups: + if group in group_allocation.groups: + evaluation_event.reason = VaraintAssignmentReason.GROUP + return group_allocation.variant, evaluation_event if feature_flag.allocation.percentile: - user = kwargs.get("user", "") context_id = user + "\n" + feature_flag.allocation.seed box = self._is_targeted(context_id) for percentile_allocation in feature_flag.allocation.percentile: if box == 100 and percentile_allocation.percentile_to == 100: return percentile_allocation.variant if percentile_allocation.percentile_from <= box < percentile_allocation.percentile_to: - return percentile_allocation.variant - return None + evaluation_event.reason = VaraintAssignmentReason.PERCENTILE + return percentile_allocation.variant, evaluation_event + return None, evaluation_event def _variant_name_to_variant(self, feature_flag, variant_name): if not feature_flag.variants: @@ -163,7 +166,7 @@ def is_enabled(self, feature_flag_id, **kwargs): :return: True if the feature flag is enabled for the given context :rtype: bool """ - return self._check_feature(feature_flag_id, **kwargs)["enabled"] + return self._check_feature(feature_flag_id, **kwargs).enabled def get_variant(self, feature_flag_id, **kwargs): """ @@ -174,7 +177,62 @@ def get_variant(self, feature_flag_id, **kwargs): :return: Name of the variant :rtype: str """ - return self._check_feature(feature_flag_id, **kwargs)["variant"] + result = self._check_feature(feature_flag_id, **kwargs) + if self._telemetry and result.feature.telemetry.enabled: + result.user = kwargs.get("user", "") + self._telemetry(result) + return result.variant + + def _check_feature_filters(self, feature_flag, evaluation_event, **kwargs): + feature_conditions = feature_flag.conditions + feature_filters = feature_conditions.client_filters + + if len(feature_filters) == 0: + # Feature flags without any filters return evaluate + evaluation_event.enabled = True + else: + # The assumed value is no filters is based on the requirement type. + # Requirement type Any assumes false until proven true, All assumes true until proven false + evaluation_event.enabled = feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL + + for feature_filter in feature_filters: + filter_name = feature_filter[FEATURE_FILTER_NAME] + if filter_name not in self._filters: + raise ValueError(f"Feature flag {feature_flag.name} has unknown filter {filter_name}") + if feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL: + if not self._filters[filter_name].evaluate(feature_filter, **kwargs): + evaluation_event.enabled = False + break + elif self._filters[filter_name].evaluate(feature_filter, **kwargs): + evaluation_event.enabled = True + break + return evaluation_event + + def _assign_allocation(self, feature_flag, evaluation_event, **kwargs): + if feature_flag.allocation and feature_flag.variants: + default_enabled = evaluation_event.enabled + variant_name, evaluation_event = self._assign_variant(feature_flag, **kwargs) + evaluation_event.enabled = default_enabled + if variant_name: + evaluation_event = FeatureManager._check_variant_override( + feature_flag.variants, variant_name, evaluation_event.enabled + ) + evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) + evaluation_event.feature = feature_flag + return evaluation_event + + variant_name = None + if evaluation_event.enabled: + evaluation_event = FeatureManager._check_default_enabled_variant(feature_flag) + if feature_flag.allocation: + variant_name = feature_flag.allocation.default_when_enabled + else: + evaluation_event = FeatureManager._check_default_disabled_variant(feature_flag) + if feature_flag.allocation: + variant_name = feature_flag.allocation.default_when_disabled + evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) + evaluation_event.feature = feature_flag + return evaluation_event def _check_feature(self, feature_flag_id, **kwargs): """ @@ -185,7 +243,7 @@ def _check_feature(self, feature_flag_id, **kwargs): :return: True if the feature flag is enabled for the given context :rtype: bool """ - result = {"enabled": None, "variant": None} + evaluation_event = EvaluationEvent(enabled=False) if self._copy is not self._configuration.get(FEATURE_MANAGEMENT_KEY): self._cache = {} self._copy = self._configuration.get(FEATURE_MANAGEMENT_KEY) @@ -199,59 +257,20 @@ def _check_feature(self, feature_flag_id, **kwargs): if not feature_flag: logging.warning("Feature flag %s not found", feature_flag_id) # Unknown feature flags are disabled by default - return result + return evaluation_event if not feature_flag.enabled: # Feature flags that are disabled are always disabled - result["enabled"] = FeatureManager._check_default_disabled_variant(feature_flag) + evaluation_event = FeatureManager._check_default_disabled_variant(feature_flag) if feature_flag.allocation: variant_name = feature_flag.allocation.default_when_disabled - result["variant"] = self._variant_name_to_variant(feature_flag, variant_name) - return result + evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) + evaluation_event.feature = feature_flag + return evaluation_event - feature_conditions = feature_flag.conditions - feature_filters = feature_conditions.client_filters + evaluation_event = self._check_feature_filters(feature_flag, evaluation_event, **kwargs) - if len(feature_filters) == 0: - # Feature flags without any filters return evaluate - result["enabled"] = True - else: - # The assumed value is no filters is based on the requirement type. - # Requirement type Any assumes false until proven true, All assumes true until proven false - result["enabled"] = feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL - - for feature_filter in feature_filters: - filter_name = feature_filter[FEATURE_FILTER_NAME] - if filter_name not in self._filters: - raise ValueError(f"Feature flag {feature_flag_id} has unknown filter {filter_name}") - if feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL: - if not self._filters[filter_name].evaluate(feature_filter, **kwargs): - result["enabled"] = False - break - elif self._filters[filter_name].evaluate(feature_filter, **kwargs): - result["enabled"] = True - break - - if feature_flag.allocation and feature_flag.variants: - variant_name = self._assign_variant(feature_flag, **kwargs) - if variant_name: - result["enabled"] = FeatureManager._check_variant_override( - feature_flag.variants, variant_name, result["enabled"] - ) - result["variant"] = self._variant_name_to_variant(feature_flag, variant_name) - return result - - variant_name = None - if result["enabled"]: - result["enabled"] = FeatureManager._check_default_enabled_variant(feature_flag) - if feature_flag.allocation: - variant_name = feature_flag.allocation.default_when_enabled - else: - result["enabled"] = FeatureManager._check_default_disabled_variant(feature_flag) - if feature_flag.allocation: - variant_name = feature_flag.allocation.default_when_disabled - result["variant"] = self._variant_name_to_variant(feature_flag, variant_name) - return result + return self._assign_allocation(feature_flag, evaluation_event, **kwargs) def list_feature_flag_names(self): """ diff --git a/featuremanagement/_models/__init__.py b/featuremanagement/_models/__init__.py index d55ccad..4287a6d 100644 --- a/featuremanagement/_models/__init__.py +++ b/featuremanagement/_models/__init__.py @@ -1 +1,18 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- +from ._feature_flag import FeatureFlag +from ._variant import Variant +from ._evaluation_event import EvaluationEvent +from ._variant_assignment_reason import VaraintAssignmentReason + __path__ = __import__("pkgutil").extend_path(__path__, __name__) # type: ignore + +__all__ = [ + "FeatureFlag", + "Variant", + "EvaluationEvent", + "VaraintAssignmentReason", +] diff --git a/featuremanagement/_models/_evaluation_event.py b/featuremanagement/_models/_evaluation_event.py new file mode 100644 index 0000000..2c2569a --- /dev/null +++ b/featuremanagement/_models/_evaluation_event.py @@ -0,0 +1,23 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- +from dataclasses import dataclass + + +@dataclass +class EvaluationEvent: + """ + Represents an evaluation event + """ + + def __init__(self, *, enabled=False, feature_flag=None): + """ + Initialize the EvaluationEvent + """ + self.feature = feature_flag + self.user = "" + self.enabled = enabled + self.variant = None + self.reason = None diff --git a/featuremanagement/_models/_feature_flag.py b/featuremanagement/_models/_feature_flag.py index 752db19..5c05240 100644 --- a/featuremanagement/_models/_feature_flag.py +++ b/featuremanagement/_models/_feature_flag.py @@ -6,6 +6,7 @@ from ._feature_conditions import FeatureConditions from ._allocation import Allocation from ._variant_reference import VariantReference +from ._telemetry import Telemetry from ._constants import ( FEATURE_FLAG_ID, FEATURE_FLAG_ENABLED, @@ -26,6 +27,7 @@ def __init__(self): self._conditions = FeatureConditions() self._allocation = None self._variants = None + self._telemetry = Telemetry() @classmethod def convert_from_json(cls, json_value): @@ -60,6 +62,8 @@ def convert_from_json(cls, json_value): feature_flag._variants = [] for variant in variants: feature_flag._variants.append(VariantReference.convert_from_json(variant)) + if "telemetry" in json_value: + feature_flag._telemetry = Telemetry(**json_value.get("telemetry")) feature_flag._validate() return feature_flag @@ -113,6 +117,16 @@ def variants(self): """ return self._variants + @property + def telemetry(self): + """ + Get the telemetry for the feature flag + + :return: Telemetry for the feature flag + :rtype: Telemetry + """ + return self._telemetry + def _validate(self): if not isinstance(self._id, str): raise ValueError("Feature flag id field must be a string.") diff --git a/featuremanagement/_models/_telemetry.py b/featuremanagement/_models/_telemetry.py new file mode 100644 index 0000000..dba3329 --- /dev/null +++ b/featuremanagement/_models/_telemetry.py @@ -0,0 +1,16 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- +from dataclasses import dataclass + + +@dataclass +class Telemetry: + """ + Represents the telemetry for a feature flag + """ + + enbled: bool = False + metadata: dict = None diff --git a/featuremanagement/_models/_variant_assignment_reason.py b/featuremanagement/_models/_variant_assignment_reason.py new file mode 100644 index 0000000..32dfa43 --- /dev/null +++ b/featuremanagement/_models/_variant_assignment_reason.py @@ -0,0 +1,19 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- +from enum import Enum + + +class VaraintAssignmentReason(Enum): + """ + Represents an assignment reason + """ + + NONE = "NONE" + DEFAULT_WHEN_DISABLED = "DEFAULT_WHEN_DISABLED" + DEFAULT_WHEN_ENABLED = "DEFAULT_WHEN_ENABLED" + USER = "USER" + GROUP = "GROUP" + PERCENTILE = "PERCENTILE" diff --git a/featuremanagement/_send_telemetry_appinsights.py b/featuremanagement/_send_telemetry_appinsights.py new file mode 100644 index 0000000..0a13a91 --- /dev/null +++ b/featuremanagement/_send_telemetry_appinsights.py @@ -0,0 +1,41 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +import logging + +try: + from azure.monitor.events.extension import track_event + + HAS_AZURE_MONITOR_EVENTS_EXTENSION = True +except ImportError: + HAS_AZURE_MONITOR_EVENTS_EXTENSION = False + logging.warning( + "azure-monitor-events-extension is not installed. Telemetry will not be sent to Application Insights." + ) + +FEATURE_NAME = "FeatureName" +ENABLED = "Enabled" +TARGETING_ID = "TargetingId" +VARIANT = "Variant" +REASON = "Reason" + +EVENT_NAME = "FeatureEvaluation" + + +def send_telemetry_appinsights(evaluation_event): + """ + Send telemetry for feature evaluation events. + """ + event = {} + event[FEATURE_NAME] = evaluation_event.feature.name + event[ENABLED] = str(evaluation_event.enabled) + if evaluation_event.user: + event[TARGETING_ID] = evaluation_event.user + + if evaluation_event.reason: + event[VARIANT] = evaluation_event.variant.name + event[REASON] = evaluation_event.reason.value + if HAS_AZURE_MONITOR_EVENTS_EXTENSION: + track_event(EVENT_NAME, event) diff --git a/featuremanagement/aio/_featuremanager.py b/featuremanagement/aio/_featuremanager.py index d67932d..d7d14de 100644 --- a/featuremanagement/aio/_featuremanager.py +++ b/featuremanagement/aio/_featuremanager.py @@ -6,6 +6,7 @@ from collections.abc import Mapping import logging import hashlib +import inspect from ._defaultfilters import TimeWindowFilter, TargetingFilter from ._featurefilters import FeatureFilter from .._featuremanager import ( @@ -16,7 +17,7 @@ _get_feature_flag, _list_feature_flag_names, ) -from .._models._variant import Variant +from .._models import FeatureFlag, Variant, EvaluationEvent, VaraintAssignmentReason class FeatureManager: @@ -27,6 +28,8 @@ class FeatureManager: :type configuration: Mapping :keyword feature_filters: Custom filters to be used for evaluating feature flags :paramtype feature_filters: list[FeatureFilter] + :keyword telemetry: Telemetry callback function + :paramtype telemetry: Callable[EvaluationEvent] """ def __init__(self, configuration, **kwargs): @@ -36,7 +39,7 @@ def __init__(self, configuration, **kwargs): self._configuration = configuration self._cache = {} self._copy = configuration.get(FEATURE_MANAGEMENT_KEY) - + self._telemetry = kwargs.pop("telemetry", None) filters = [TimeWindowFilter(), TargetingFilter()] + kwargs.pop(PROVIDED_FEATURE_FILTERS, []) for feature_filter in filters: @@ -47,7 +50,7 @@ def __init__(self, configuration, **kwargs): @staticmethod def _check_default_disabled_variant(feature_flag): if not feature_flag.allocation: - return False + return EvaluationEvent(enabled=False) return FeatureManager._check_variant_override( feature_flag.variants, feature_flag.allocation.default_when_disabled, False ) @@ -55,7 +58,7 @@ def _check_default_disabled_variant(feature_flag): @staticmethod def _check_default_enabled_variant(feature_flag): if not feature_flag.allocation: - return True + return EvaluationEvent(enabled=True) return FeatureManager._check_variant_override( feature_flag.variants, feature_flag.allocation.default_when_enabled, True ) @@ -63,14 +66,14 @@ def _check_default_enabled_variant(feature_flag): @staticmethod def _check_variant_override(variants, default_variant_name, status): if not variants or not default_variant_name: - return status + return EvaluationEvent(enabled=status) for variant in variants: if variant.name == default_variant_name: if variant.status_override == "Enabled": - return True + return EvaluationEvent(enabled=True) if variant.status_override == "Disabled": - return False - return status + return EvaluationEvent(enabled=False) + return EvaluationEvent(enabled=status) @staticmethod def _is_targeted(context_id): @@ -81,21 +84,22 @@ def _is_targeted(context_id): return (context_marker / (2**32 - 1)) * 100 def _assign_variant(self, feature_flag, **kwargs): + user = kwargs.get("user", "") + groups = kwargs.get("groups", []) + evaluation_event = EvaluationEvent(feature_flag=feature_flag) if not feature_flag.variants or not feature_flag.allocation: return None - if feature_flag.allocation.user: - user = kwargs.get("user") - if user: - for user_allocation in feature_flag.allocation.user: - if user in user_allocation.users: - return user_allocation.variant - if feature_flag.allocation.group: - groups = kwargs.get("groups") - if groups: - for group_allocation in feature_flag.allocation.group: - for group in groups: - if group in group_allocation.groups: - return group_allocation.variant + if feature_flag.allocation.user and user: + for user_allocation in feature_flag.allocation.user: + if user in user_allocation.users: + evaluation_event.reason = VaraintAssignmentReason.USER + return user_allocation.variant, evaluation_event + if feature_flag.allocation.group and groups: + for group_allocation in feature_flag.allocation.group: + for group in groups: + if group in group_allocation.groups: + evaluation_event.reason = VaraintAssignmentReason.GROUP + return group_allocation.variant, evaluation_event if feature_flag.allocation.percentile: user = kwargs.get("user", "") context_id = user + "\n" + feature_flag.allocation.seed @@ -104,8 +108,9 @@ def _assign_variant(self, feature_flag, **kwargs): if box == 100 and percentile_allocation.percentile_to == 100: return percentile_allocation.variant if percentile_allocation.percentile_from <= box < percentile_allocation.percentile_to: - return percentile_allocation.variant - return None + evaluation_event.reason = VaraintAssignmentReason.PERCENTILE + return percentile_allocation.variant, evaluation_event + return None, evaluation_event def _variant_name_to_variant(self, feature_flag, variant_name): if not feature_flag.variants: @@ -127,7 +132,7 @@ async def is_enabled(self, feature_flag_id, **kwargs): :return: True if the feature flag is enabled for the given context :rtype: bool """ - return (await self._check_feature(feature_flag_id, **kwargs))["enabled"] + return (await self._check_feature(feature_flag_id, **kwargs)).enabled async def get_variant(self, feature_flag_id, **kwargs): """ @@ -138,85 +143,103 @@ async def get_variant(self, feature_flag_id, **kwargs): :return: Name of the variant :rtype: str """ - return (await self._check_feature(feature_flag_id, **kwargs))["variant"] - - async def _check_feature(self, feature_flag_id, **kwargs): - """ - Determine if the feature flag is enabled for the given context - - :param str feature_flag_id: Name of the feature flag - :paramtype feature_flag_id: str - :return: True if the feature flag is enabled for the given context - :rtype: bool - """ - result = {"enabled": None, "variant": None} - if self._copy is not self._configuration.get(FEATURE_MANAGEMENT_KEY): - self._cache = {} - self._copy = self._configuration.get(FEATURE_MANAGEMENT_KEY) - - if not self._cache.get(feature_flag_id): - feature_flag = _get_feature_flag(self._configuration, feature_flag_id) - self._cache[feature_flag_id] = feature_flag - else: - feature_flag = self._cache.get(feature_flag_id) - - if not feature_flag: - logging.warning("Feature flag %s not found", feature_flag_id) - # Unknown feature flags are disabled by default - return result - - if not feature_flag.enabled: - # Feature flags that are disabled are always disabled - result["enabled"] = FeatureManager._check_default_disabled_variant(feature_flag) - if feature_flag.allocation: - variant_name = feature_flag.allocation.default_when_disabled - result["variant"] = self._variant_name_to_variant(feature_flag, variant_name) - return result - + result = await self._check_feature(feature_flag_id, **kwargs) + if self._telemetry and result.feature.telemetry.enabled: + result.user = kwargs.get("user", "") + if inspect.iscoroutinefunction(self._telemetry): + await self._telemetry(result) + else: + self._telemetry(result) + return result.variant + + async def _check_feature_filters(self, feature_flag, evaluation_event, **kwargs): feature_conditions = feature_flag.conditions feature_filters = feature_conditions.client_filters if len(feature_filters) == 0: # Feature flags without any filters return evaluate - result["enabled"] = True + evaluation_event.enabled = True else: # The assumed value is no filters is based on the requirement type. # Requirement type Any assumes false until proven true, All assumes true until proven false - result["enabled"] = feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL + evaluation_event.enabled = feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL for feature_filter in feature_filters: filter_name = feature_filter[FEATURE_FILTER_NAME] if filter_name not in self._filters: - raise ValueError(f"Feature flag {feature_flag_id} has unknown filter {filter_name}") + raise ValueError(f"Feature flag {feature_flag.name} has unknown filter {filter_name}") if feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL: if not await self._filters[filter_name].evaluate(feature_filter, **kwargs): - result["enabled"] = False + evaluation_event.enabled = False break else: if await self._filters[filter_name].evaluate(feature_filter, **kwargs): - result["enabled"] = True + evaluation_event.enabled = True break - + return evaluation_event + + def _assign_allocation(self, feature_flag, evaluation_event, **kwargs): if feature_flag.allocation and feature_flag.variants: - variant_name = self._assign_variant(feature_flag, **kwargs) + default_enabled = evaluation_event.enabled + variant_name, evaluation_event = self._assign_variant(feature_flag, **kwargs) + evaluation_event.enabled = default_enabled if variant_name: - result["enabled"] = FeatureManager._check_variant_override( - feature_flag.variants, variant_name, result["enabled"] + evaluation_event = FeatureManager._check_variant_override( + feature_flag.variants, variant_name, evaluation_event.enabled ) - result["variant"] = self._variant_name_to_variant(feature_flag, variant_name) - return result + evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) + evaluation_event.feature = feature_flag + return evaluation_event variant_name = None - if result["enabled"]: - result["enabled"] = FeatureManager._check_default_enabled_variant(feature_flag) + if evaluation_event.enabled: + evaluation_event = FeatureManager._check_default_enabled_variant(feature_flag) if feature_flag.allocation: variant_name = feature_flag.allocation.default_when_enabled else: - result["enabled"] = FeatureManager._check_default_disabled_variant(feature_flag) + evaluation_event = FeatureManager._check_default_disabled_variant(feature_flag) + if feature_flag.allocation: + variant_name = feature_flag.allocation.default_when_disabled + evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) + evaluation_event.feature = feature_flag + return evaluation_event + + async def _check_feature(self, feature_flag_id, **kwargs): + """ + Determine if the feature flag is enabled for the given context + + :param str feature_flag_id: Name of the feature flag + :paramtype feature_flag_id: str + :return: True if the feature flag is enabled for the given context + :rtype: bool + """ + evaluation_event = EvaluationEvent(enabled=False) + if self._copy is not self._configuration.get(FEATURE_MANAGEMENT_KEY): + self._cache = {} + self._copy = self._configuration.get(FEATURE_MANAGEMENT_KEY) + + if not self._cache.get(feature_flag_id): + feature_flag = _get_feature_flag(self._configuration, feature_flag_id) + self._cache[feature_flag_id] = feature_flag + else: + feature_flag = self._cache.get(feature_flag_id) + + if not feature_flag: + logging.warning("Feature flag %s not found", feature_flag_id) + # Unknown feature flags are disabled by default + return evaluation_event + + if not feature_flag.enabled: + # Feature flags that are disabled are always disabled + evaluation_event = FeatureManager._check_default_disabled_variant(feature_flag) if feature_flag.allocation: variant_name = feature_flag.allocation.default_when_disabled - result["variant"] = self._variant_name_to_variant(feature_flag, variant_name) - return result + evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) + evaluation_event.feature = feature_flag + return evaluation_event + + evaluation_event = await self._check_feature_filters(feature_flag, evaluation_event, **kwargs) + return self._assign_allocation(feature_flag, evaluation_event, **kwargs) def list_feature_flag_names(self): """ diff --git a/pyproject.toml b/pyproject.toml index effe11d..d60a446 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,3 +42,6 @@ classifiers = [ [project.urls] Homepage = "https://github.com/microsoft/FeatureManagement-Python" Issues = "https://github.com/microsoft/FeatureManagement-Python/issues" + +[project.optional-dependencies] +AppInsights = ["azure-monitor-opentelemetry<2.0.0,>=1.3.0","azure-monitor-events-extension<2.0.0"] diff --git a/samples/feature_variant_sample_with_telemetry.py b/samples/feature_variant_sample_with_telemetry.py new file mode 100644 index 0000000..3ca3817 --- /dev/null +++ b/samples/feature_variant_sample_with_telemetry.py @@ -0,0 +1,30 @@ +# ------------------------------------------------------------------------- +# 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, send_telemetry_appinsights + +try: + from azure.monitor.opentelemetry import configure_azure_monitor + + # Configure Azure Monitor + configure_azure_monitor(connection_string=os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING")) +except ImportError: + pass + +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) + +# Initialize the feature manager with telemetry callback +feature_manager = FeatureManager(feature_flags, feature_filters=[RandomFilter()], telemetry=send_telemetry_appinsights) + +# Evaluate the feature flag for the user +print(feature_manager.get_variant("TestVariants", user="Adam").configuration) diff --git a/samples/formatted_feature_flags.json b/samples/formatted_feature_flags.json index 772f250..e541f2b 100644 --- a/samples/formatted_feature_flags.json +++ b/samples/formatted_feature_flags.json @@ -164,6 +164,12 @@ "id": "TestVariants", "description": "", "enabled": "true", + "telemetry": { + "enabled": "true", + "metadata": { + "etag": "my-fake-etag" + } + }, "conditions": { "client_filters": [ { diff --git a/setup.py b/setup.py index 40bc75b..f0a809a 100644 --- a/setup.py +++ b/setup.py @@ -58,4 +58,7 @@ packages=find_packages(), python_requires=">=3.6", install_requires=[], + extras_require={ + "AppInsights": ["azure-monitor-opentelemetry<2.0.0,>=1.3.0", "azure-monitor-events-extension<2.0.0"], + }, ) diff --git a/tests/test_send_telemetry_appinsights.py b/tests/test_send_telemetry_appinsights.py new file mode 100644 index 0000000..5091048 --- /dev/null +++ b/tests/test_send_telemetry_appinsights.py @@ -0,0 +1,84 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +import sys +import logging +from importlib import reload +from unittest.mock import patch +import pytest +from featuremanagement import EvaluationEvent, send_telemetry_appinsights, FeatureFlag, Variant, VaraintAssignmentReason + + +@pytest.mark.usefixtures("caplog") +class TestSendTelemetryAppinsights: + + def test_send_telemetry_appinsights(self): + evaluation_event = EvaluationEvent() + feature_flag = FeatureFlag.convert_from_json({"id": "TestFeature"}) + variant = Variant("TestVariant", None) + evaluation_event.feature = feature_flag + evaluation_event.enabled = True + evaluation_event.user = "test_user" + evaluation_event.variant = variant + evaluation_event.reason = VaraintAssignmentReason.NONE + + with patch("featuremanagement._send_telemetry_appinsights.track_event") as mock_track_event: + send_telemetry_appinsights(evaluation_event) + mock_track_event.assert_called_once() + assert mock_track_event.call_args[0][0] == "FeatureEvaluation" + assert mock_track_event.call_args[0][1]["FeatureName"] == "TestFeature" + assert mock_track_event.call_args[0][1]["Enabled"] == "True" + assert mock_track_event.call_args[0][1]["TargetingId"] == "test_user" + assert mock_track_event.call_args[0][1]["Variant"] == "TestVariant" + assert mock_track_event.call_args[0][1]["Reason"] == "NONE" + + def test_send_telemetry_appinsights_no_user(self): + evaluation_event = EvaluationEvent() + feature_flag = FeatureFlag.convert_from_json({"id": "TestFeature"}) + variant = Variant("TestVariant", None) + evaluation_event.feature = feature_flag + evaluation_event.enabled = False + evaluation_event.variant = variant + evaluation_event.reason = VaraintAssignmentReason.NONE + + with patch("featuremanagement._send_telemetry_appinsights.track_event") as mock_track_event: + send_telemetry_appinsights(evaluation_event) + mock_track_event.assert_called_once() + assert mock_track_event.call_args[0][0] == "FeatureEvaluation" + assert mock_track_event.call_args[0][1]["FeatureName"] == "TestFeature" + assert mock_track_event.call_args[0][1]["Enabled"] == "False" + assert "TargetingId" not in mock_track_event.call_args[0][1] + assert mock_track_event.call_args[0][1]["Variant"] == "TestVariant" + assert mock_track_event.call_args[0][1]["Reason"] == "NONE" + + def test_send_telemetry_appinsights_no_variant(self): + evaluation_event = EvaluationEvent() + feature_flag = FeatureFlag.convert_from_json({"id": "TestFeature"}) + evaluation_event.feature = feature_flag + evaluation_event.enabled = True + evaluation_event.user = "test_user" + + with patch("featuremanagement._send_telemetry_appinsights.track_event") as mock_track_event: + send_telemetry_appinsights(evaluation_event) + mock_track_event.assert_called_once() + assert mock_track_event.call_args[0][0] == "FeatureEvaluation" + assert mock_track_event.call_args[0][1]["FeatureName"] == "TestFeature" + assert mock_track_event.call_args[0][1]["Enabled"] == "True" + assert mock_track_event.call_args[0][1]["TargetingId"] == "test_user" + assert "Variant" not in mock_track_event.call_args[0][1] + assert "Reason" not in mock_track_event.call_args[0][1] + + def test_send_telemetry_appinsights_no_import(self, caplog): + evaluation_event = EvaluationEvent() + feature_flag = FeatureFlag.convert_from_json({"id": "TestFeature"}) + evaluation_event.feature = feature_flag + evaluation_event.enabled = True + + with patch.dict("sys.modules", {"azure.monitor.events.extension": None}): + reload(sys.modules["featuremanagement._send_telemetry_appinsights"]) + caplog.set_level(logging.WARNING) + send_telemetry_appinsights(evaluation_event) + assert "Telemetry will not be sent to Application Insights." in caplog.text From a9847cc8a878b11b01d1bda210bf18d4a4c8563c Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Mon, 15 Apr 2024 09:29:46 -0700 Subject: [PATCH 02/35] updating from feedback --- featuremanagement/__init__.py | 2 - featuremanagement/_featuremanager.py | 4 +- .../_models/_feature_conditions.py | 2 + featuremanagement/_models/_telemetry.py | 2 +- .../_models/_variant_assignment_reason.py | 10 ++-- .../_send_telemetry_appinsights.py | 41 ------------- featuremanagement/appinsights/__init__.py | 13 ++++ .../_send_telemetry_appinsights.py | 60 +++++++++++++++++++ .../feature_variant_sample_with_telemetry.py | 7 ++- 9 files changed, 87 insertions(+), 54 deletions(-) delete mode 100644 featuremanagement/_send_telemetry_appinsights.py create mode 100644 featuremanagement/appinsights/__init__.py create mode 100644 featuremanagement/appinsights/_send_telemetry_appinsights.py diff --git a/featuremanagement/__init__.py b/featuremanagement/__init__.py index 8e5bfc7..5622899 100644 --- a/featuremanagement/__init__.py +++ b/featuremanagement/__init__.py @@ -7,7 +7,6 @@ from ._featurefilters import FeatureFilter from ._defaultfilters import TimeWindowFilter, TargetingFilter from ._models import FeatureFlag, Variant, EvaluationEvent, VaraintAssignmentReason -from ._send_telemetry_appinsights import send_telemetry_appinsights from ._version import VERSION @@ -21,5 +20,4 @@ "Variant", "EvaluationEvent", "VaraintAssignmentReason", - "send_telemetry_appinsights", ] diff --git a/featuremanagement/_featuremanager.py b/featuremanagement/_featuremanager.py index b572c31..25bf100 100644 --- a/featuremanagement/_featuremanager.py +++ b/featuremanagement/_featuremanager.py @@ -214,9 +214,9 @@ def _assign_allocation(self, feature_flag, evaluation_event, **kwargs): variant_name, evaluation_event = self._assign_variant(feature_flag, **kwargs) evaluation_event.enabled = default_enabled if variant_name: - evaluation_event = FeatureManager._check_variant_override( + evaluation_event.enabled = FeatureManager._check_variant_override( feature_flag.variants, variant_name, evaluation_event.enabled - ) + ).enabled evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) evaluation_event.feature = feature_flag return evaluation_event diff --git a/featuremanagement/_models/_feature_conditions.py b/featuremanagement/_models/_feature_conditions.py index f300d92..9e6ab54 100644 --- a/featuremanagement/_models/_feature_conditions.py +++ b/featuremanagement/_models/_feature_conditions.py @@ -37,6 +37,8 @@ def convert_from_json(cls, feature_name, json_value): raise AttributeError("Feature flag conditions must be a dictionary") conditions._requirement_type = json_value.get(FEATURE_FILTER_REQUIREMENT_TYPE, REQUIREMENT_TYPE_ANY) conditions._client_filters = json_value.get(FEATURE_FLAG_CLIENT_FILTERS, []) + if not isinstance(conditions._client_filters, list): + conditions._client_filters = [] for feature_filter in conditions._client_filters: feature_filter["feature_name"] = feature_name return conditions diff --git a/featuremanagement/_models/_telemetry.py b/featuremanagement/_models/_telemetry.py index dba3329..36c424e 100644 --- a/featuremanagement/_models/_telemetry.py +++ b/featuremanagement/_models/_telemetry.py @@ -12,5 +12,5 @@ class Telemetry: Represents the telemetry for a feature flag """ - enbled: bool = False + enabled: bool = False metadata: dict = None diff --git a/featuremanagement/_models/_variant_assignment_reason.py b/featuremanagement/_models/_variant_assignment_reason.py index 32dfa43..38c53de 100644 --- a/featuremanagement/_models/_variant_assignment_reason.py +++ b/featuremanagement/_models/_variant_assignment_reason.py @@ -12,8 +12,8 @@ class VaraintAssignmentReason(Enum): """ NONE = "NONE" - DEFAULT_WHEN_DISABLED = "DEFAULT_WHEN_DISABLED" - DEFAULT_WHEN_ENABLED = "DEFAULT_WHEN_ENABLED" - USER = "USER" - GROUP = "GROUP" - PERCENTILE = "PERCENTILE" + DEFAULT_WHEN_DISABLED = "DefaultWhenDisabled" + DEFAULT_WHEN_ENABLED = "DefaultWhenEnabled" + USER = "User" + GROUP = "Group" + PERCENTILE = "Percentile" diff --git a/featuremanagement/_send_telemetry_appinsights.py b/featuremanagement/_send_telemetry_appinsights.py deleted file mode 100644 index 0a13a91..0000000 --- a/featuremanagement/_send_telemetry_appinsights.py +++ /dev/null @@ -1,41 +0,0 @@ -# ------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for -# license information. -# -------------------------------------------------------------------------- -import logging - -try: - from azure.monitor.events.extension import track_event - - HAS_AZURE_MONITOR_EVENTS_EXTENSION = True -except ImportError: - HAS_AZURE_MONITOR_EVENTS_EXTENSION = False - logging.warning( - "azure-monitor-events-extension is not installed. Telemetry will not be sent to Application Insights." - ) - -FEATURE_NAME = "FeatureName" -ENABLED = "Enabled" -TARGETING_ID = "TargetingId" -VARIANT = "Variant" -REASON = "Reason" - -EVENT_NAME = "FeatureEvaluation" - - -def send_telemetry_appinsights(evaluation_event): - """ - Send telemetry for feature evaluation events. - """ - event = {} - event[FEATURE_NAME] = evaluation_event.feature.name - event[ENABLED] = str(evaluation_event.enabled) - if evaluation_event.user: - event[TARGETING_ID] = evaluation_event.user - - if evaluation_event.reason: - event[VARIANT] = evaluation_event.variant.name - event[REASON] = evaluation_event.reason.value - if HAS_AZURE_MONITOR_EVENTS_EXTENSION: - track_event(EVENT_NAME, event) diff --git a/featuremanagement/appinsights/__init__.py b/featuremanagement/appinsights/__init__.py new file mode 100644 index 0000000..18368b3 --- /dev/null +++ b/featuremanagement/appinsights/__init__.py @@ -0,0 +1,13 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- + +from ._send_telemetry_appinsights import send_telemetry, track_event + + +__all__ = [ + "send_telemetry", + "track_event", +] diff --git a/featuremanagement/appinsights/_send_telemetry_appinsights.py b/featuremanagement/appinsights/_send_telemetry_appinsights.py new file mode 100644 index 0000000..2884eea --- /dev/null +++ b/featuremanagement/appinsights/_send_telemetry_appinsights.py @@ -0,0 +1,60 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +import logging +from .._models import VaraintAssignmentReason + +try: + from azure.monitor.events.extension import track_event as appinsights_track_event + + HAS_AZURE_MONITOR_EVENTS_EXTENSION = True +except ImportError: + HAS_AZURE_MONITOR_EVENTS_EXTENSION = False + logging.warning( + "azure-monitor-events-extension is not installed. Telemetry will not be sent to Application Insights." + ) + +FEATURE_NAME = "FeatureName" +ENABLED = "Enabled" +TARGETING_ID = "TargetingId" +VARIANT = "Variant" +REASON = "VariantAssignmentReason" + +EVENT_NAME = "FeatureEvaluation" + +def track_event(event_name, user, event_properties=None): + """ + Track an event with the specified name and properties. + + :param str event_name: The name of the event. + :param dict[str, str] event_properties: A dictionary of named string properties. + """ + if not HAS_AZURE_MONITOR_EVENTS_EXTENSION: + return + if event_properties is None: + event_properties = {} + event_properties[TARGETING_ID] = user + appinsights_track_event(event_name, event_properties) + +def send_telemetry(evaluation_event): + """ + Send telemetry for feature evaluation events. + """ + if not HAS_AZURE_MONITOR_EVENTS_EXTENSION: + return + event = {} + event[FEATURE_NAME] = evaluation_event.feature.name + event[ENABLED] = str(evaluation_event.enabled) + if evaluation_event.user: + event[TARGETING_ID] = evaluation_event.user + + if evaluation_event.reason and evaluation_event.reason != VaraintAssignmentReason.NONE: + event[VARIANT] = evaluation_event.variant.name + event[REASON] = evaluation_event.reason.value + + event["ETag"] = evaluation_event.feature.telemetry.metadata.get("etag") + event["FeatureFlagReference"] = evaluation_event.feature.telemetry.metadata.get("feature_flag_reference") + event["FeatureFlagId"] = evaluation_event.feature.telemetry.metadata.get("feature_flag_id") + appinsights_track_event(EVENT_NAME, event) diff --git a/samples/feature_variant_sample_with_telemetry.py b/samples/feature_variant_sample_with_telemetry.py index 3ca3817..d627a2d 100644 --- a/samples/feature_variant_sample_with_telemetry.py +++ b/samples/feature_variant_sample_with_telemetry.py @@ -8,7 +8,8 @@ import os import sys from random_filter import RandomFilter -from featuremanagement import FeatureManager, send_telemetry_appinsights +from featuremanagement import FeatureManager +from featuremanagement.appinsights import send_telemetry_appinsights try: from azure.monitor.opentelemetry import configure_azure_monitor @@ -24,7 +25,7 @@ feature_flags = json.load(f) # Initialize the feature manager with telemetry callback -feature_manager = FeatureManager(feature_flags, feature_filters=[RandomFilter()], telemetry=send_telemetry_appinsights) +feature_manager = FeatureManager(feature_flags, feature_filters=[RandomFilter()]) # Evaluate the feature flag for the user -print(feature_manager.get_variant("TestVariants", user="Adam").configuration) +print(feature_manager.get_variant("TestVariants", user="Adam", telemetry=send_telemetry_appinsights).configuration) From de6a442db6b9080b3dc28b82e1b61a08eb3f820a Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Wed, 17 Apr 2024 12:47:26 -0700 Subject: [PATCH 03/35] Changes from design meeting --- featuremanagement/__init__.py | 3 +- featuremanagement/_featuremanager.py | 99 ++++++++++++++----- featuremanagement/_models/__init__.py | 8 +- .../_models/_targeting_context.py | 19 ++++ featuremanagement/_models/_telemetry.py | 4 +- featuremanagement/aio/_featuremanager.py | 6 +- .../{appinsights => azuremonitor}/__init__.py | 4 +- .../_send_telemetry.py} | 20 ++-- .../feature_variant_sample_with_telemetry.py | 2 +- tests/test_default_feature_flag_from_file.py | 44 ++++----- tests/test_default_feature_flags.py | 10 +- tests/test_feature_variants.py | 68 +++++++------ tests/test_send_telemetry_appinsights.py | 27 ++--- 13 files changed, 192 insertions(+), 122 deletions(-) create mode 100644 featuremanagement/_models/_targeting_context.py rename featuremanagement/{appinsights => azuremonitor}/__init__.py (79%) rename featuremanagement/{appinsights/_send_telemetry_appinsights.py => azuremonitor/_send_telemetry.py} (82%) diff --git a/featuremanagement/__init__.py b/featuremanagement/__init__.py index 5622899..80fbd9d 100644 --- a/featuremanagement/__init__.py +++ b/featuremanagement/__init__.py @@ -6,7 +6,7 @@ from ._featuremanager import FeatureManager from ._featurefilters import FeatureFilter from ._defaultfilters import TimeWindowFilter, TargetingFilter -from ._models import FeatureFlag, Variant, EvaluationEvent, VaraintAssignmentReason +from ._models import FeatureFlag, Variant, EvaluationEvent, VaraintAssignmentReason, TargetingContext from ._version import VERSION @@ -20,4 +20,5 @@ "Variant", "EvaluationEvent", "VaraintAssignmentReason", + "TargetingContext", ] diff --git a/featuremanagement/_featuremanager.py b/featuremanagement/_featuremanager.py index 25bf100..4b8fbf9 100644 --- a/featuremanagement/_featuremanager.py +++ b/featuremanagement/_featuremanager.py @@ -6,9 +6,10 @@ import logging import hashlib from collections.abc import Mapping +from typing import overload from ._defaultfilters import TimeWindowFilter, TargetingFilter from ._featurefilters import FeatureFilter -from ._models import FeatureFlag, Variant, EvaluationEvent, VaraintAssignmentReason +from ._models import FeatureFlag, Variant, EvaluationEvent, VaraintAssignmentReason, TargetingContext FEATURE_MANAGEMENT_KEY = "feature_management" @@ -63,8 +64,8 @@ class FeatureManager: :type configuration: Mapping :keyword feature_filters: Custom filters to be used for evaluating feature flags :paramtype feature_filters: list[FeatureFilter] - :keyword telemetry: Telemetry callback function - :paramtype telemetry: Callable[EvaluationEvent] + :keyword on_feature_evaluated: Callback function to be called when a feature flag is evaluated + :paramtype on_feature_evaluated: Callable[EvaluationEvent] """ def __init__(self, configuration, **kwargs): @@ -74,7 +75,7 @@ def __init__(self, configuration, **kwargs): self._configuration = configuration self._cache = {} self._copy = configuration.get(FEATURE_MANAGEMENT_KEY) - self._telemetry = kwargs.get("telemetry", None) + self._on_feature_evaluated = kwargs.get("on_feature_evaluated", None) filters = [TimeWindowFilter(), TargetingFilter()] + kwargs.pop(PROVIDED_FEATURE_FILTERS, []) for feature_filter in filters: @@ -118,25 +119,23 @@ def _is_targeted(context_id): return (context_marker / (2**32 - 1)) * 100 - def _assign_variant(self, feature_flag, **kwargs): - user = kwargs.get("user", "") - groups = kwargs.get("groups", []) + def _assign_variant(self, feature_flag, targeting_context): evaluation_event = EvaluationEvent(feature_flag=feature_flag) if not feature_flag.variants or not feature_flag.allocation: return None, evaluation_event - if feature_flag.allocation.user and user: + if feature_flag.allocation.user and targeting_context.user_id: for user_allocation in feature_flag.allocation.user: - if user in user_allocation.users: + if targeting_context.user_id in user_allocation.users: evaluation_event.reason = VaraintAssignmentReason.USER return user_allocation.variant, evaluation_event - if feature_flag.allocation.group and len(groups): + if feature_flag.allocation.group and len(targeting_context.groups) > 0: for group_allocation in feature_flag.allocation.group: - for group in groups: + for group in targeting_context.groups: if group in group_allocation.groups: evaluation_event.reason = VaraintAssignmentReason.GROUP return group_allocation.variant, evaluation_event if feature_flag.allocation.percentile: - context_id = user + "\n" + feature_flag.allocation.seed + context_id = targeting_context.user_id + "\n" + feature_flag.allocation.seed box = self._is_targeted(context_id) for percentile_allocation in feature_flag.allocation.percentile: if box == 100 and percentile_allocation.percentile_to == 100: @@ -157,33 +156,77 @@ def _variant_name_to_variant(self, feature_flag, variant_name): return Variant(variant_reference.name, configuration) return None - def is_enabled(self, feature_flag_id, **kwargs): + @overload + def is_enabled(self, feature_flag_id, user_id, **kwargs): """ Determine if the feature flag is enabled for the given context :param str feature_flag_id: Name of the feature flag :paramtype feature_flag_id: str + :param str user_id: User identifier + :paramtype user_id: str :return: True if the feature flag is enabled for the given context :rtype: bool """ - return self._check_feature(feature_flag_id, **kwargs).enabled - def get_variant(self, feature_flag_id, **kwargs): + def is_enabled(self, feature_flag_id, *args, **kwargs): + """ + Determine if the feature flag is enabled for the given context + + :param str feature_flag_id: Name of the feature flag + :paramtype feature_flag_id: str + :return: True if the feature flag is enabled for the given context + :rtype: bool + """ + targeting_context = TargetingContext() + if len(args) == 1 and isinstance(args[0], str): + targeting_context = TargetingContext(user_id=args[0], groups=[]) + elif len(args) == 1 and isinstance(args[0], TargetingContext): + targeting_context = args[0] + + result = self._check_feature(feature_flag_id, targeting_context, **kwargs) + if self._on_feature_evaluated and result.feature.telemetry.enabled: + result.user = targeting_context.user_id + self._on_feature_evaluated(result) + return result.enabled + + @overload + def get_variant(self, feature_flag_id, user_id, **kwargs): + """ + Determine the variant for the given context + + :param str feature_flag_id: Name of the feature flag + :paramtype feature_flag_id: str + :param str user_id: User identifier + :paramtype user_id: str + :return: return: Variant instance + :rtype: Variant + """ + + def get_variant(self, feature_flag_id, *args, **kwargs): """ Determine the variant for the given context :param str feature_flag_id: Name of the feature flag :paramtype feature_flag_id: str - :return: Name of the variant - :rtype: str + :kwyword targeting_context: Targeting context + :paramtype TargetingContext: TargetingContext + :return: Variant instance + :rtype: Variant """ - result = self._check_feature(feature_flag_id, **kwargs) - if self._telemetry and result.feature.telemetry.enabled: - result.user = kwargs.get("user", "") - self._telemetry(result) + targeting_context = TargetingContext() + if len(args) == 1 and isinstance(args[0], str): + targeting_context = TargetingContext(user_id=args[0], groups=[]) + elif len(args) == 1 and isinstance(args[0], TargetingContext): + targeting_context = args[0] + + result = self._check_feature(feature_flag_id, targeting_context, **kwargs) + if self._on_feature_evaluated and result.feature.telemetry.enabled: + result.user = targeting_context.user_id + self._on_feature_evaluated(result) return result.variant - def _check_feature_filters(self, feature_flag, evaluation_event, **kwargs): + def _check_feature_filters(self, feature_flag, evaluation_event, targeting_context, **kwargs): feature_conditions = feature_flag.conditions feature_filters = feature_conditions.client_filters @@ -197,6 +240,8 @@ def _check_feature_filters(self, feature_flag, evaluation_event, **kwargs): for feature_filter in feature_filters: filter_name = feature_filter[FEATURE_FILTER_NAME] + kwargs["user"] = targeting_context.user_id + kwargs["groups"] = targeting_context.groups if filter_name not in self._filters: raise ValueError(f"Feature flag {feature_flag.name} has unknown filter {filter_name}") if feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL: @@ -208,10 +253,10 @@ def _check_feature_filters(self, feature_flag, evaluation_event, **kwargs): break return evaluation_event - def _assign_allocation(self, feature_flag, evaluation_event, **kwargs): + def _assign_allocation(self, feature_flag, evaluation_event, targeting_context): if feature_flag.allocation and feature_flag.variants: default_enabled = evaluation_event.enabled - variant_name, evaluation_event = self._assign_variant(feature_flag, **kwargs) + variant_name, evaluation_event = self._assign_variant(feature_flag, targeting_context) evaluation_event.enabled = default_enabled if variant_name: evaluation_event.enabled = FeatureManager._check_variant_override( @@ -234,7 +279,7 @@ def _assign_allocation(self, feature_flag, evaluation_event, **kwargs): evaluation_event.feature = feature_flag return evaluation_event - def _check_feature(self, feature_flag_id, **kwargs): + def _check_feature(self, feature_flag_id, targeting_context, **kwargs): """ Determine if the feature flag is enabled for the given context @@ -268,9 +313,9 @@ def _check_feature(self, feature_flag_id, **kwargs): evaluation_event.feature = feature_flag return evaluation_event - evaluation_event = self._check_feature_filters(feature_flag, evaluation_event, **kwargs) + evaluation_event = self._check_feature_filters(feature_flag, evaluation_event, targeting_context, **kwargs) - return self._assign_allocation(feature_flag, evaluation_event, **kwargs) + return self._assign_allocation(feature_flag, evaluation_event, targeting_context) def list_feature_flag_names(self): """ diff --git a/featuremanagement/_models/__init__.py b/featuremanagement/_models/__init__.py index 4287a6d..bb5e88c 100644 --- a/featuremanagement/_models/__init__.py +++ b/featuremanagement/_models/__init__.py @@ -7,12 +7,8 @@ from ._variant import Variant from ._evaluation_event import EvaluationEvent from ._variant_assignment_reason import VaraintAssignmentReason +from ._targeting_context import TargetingContext __path__ = __import__("pkgutil").extend_path(__path__, __name__) # type: ignore -__all__ = [ - "FeatureFlag", - "Variant", - "EvaluationEvent", - "VaraintAssignmentReason", -] +__all__ = ["FeatureFlag", "Variant", "EvaluationEvent", "VaraintAssignmentReason", "TargetingContext"] diff --git a/featuremanagement/_models/_targeting_context.py b/featuremanagement/_models/_targeting_context.py new file mode 100644 index 0000000..3bd5086 --- /dev/null +++ b/featuremanagement/_models/_targeting_context.py @@ -0,0 +1,19 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from typing import NamedTuple + + +class TargetingContext(NamedTuple): + """ + Represents the context for targeting a feature flag. + """ + + # The user ID + user_id: str = "" + + # The users groups + groups: list[str] = [] diff --git a/featuremanagement/_models/_telemetry.py b/featuremanagement/_models/_telemetry.py index 36c424e..68b2874 100644 --- a/featuremanagement/_models/_telemetry.py +++ b/featuremanagement/_models/_telemetry.py @@ -3,7 +3,7 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # ------------------------------------------------------------------------- -from dataclasses import dataclass +from dataclasses import dataclass, field @dataclass @@ -13,4 +13,4 @@ class Telemetry: """ enabled: bool = False - metadata: dict = None + metadata: dict = field(default_factory=dict) diff --git a/featuremanagement/aio/_featuremanager.py b/featuremanagement/aio/_featuremanager.py index d7d14de..35308d6 100644 --- a/featuremanagement/aio/_featuremanager.py +++ b/featuremanagement/aio/_featuremanager.py @@ -151,7 +151,7 @@ async def get_variant(self, feature_flag_id, **kwargs): else: self._telemetry(result) return result.variant - + async def _check_feature_filters(self, feature_flag, evaluation_event, **kwargs): feature_conditions = feature_flag.conditions feature_filters = feature_conditions.client_filters @@ -177,7 +177,7 @@ async def _check_feature_filters(self, feature_flag, evaluation_event, **kwargs) evaluation_event.enabled = True break return evaluation_event - + def _assign_allocation(self, feature_flag, evaluation_event, **kwargs): if feature_flag.allocation and feature_flag.variants: default_enabled = evaluation_event.enabled @@ -237,7 +237,7 @@ async def _check_feature(self, feature_flag_id, **kwargs): evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) evaluation_event.feature = feature_flag return evaluation_event - + evaluation_event = await self._check_feature_filters(feature_flag, evaluation_event, **kwargs) return self._assign_allocation(feature_flag, evaluation_event, **kwargs) diff --git a/featuremanagement/appinsights/__init__.py b/featuremanagement/azuremonitor/__init__.py similarity index 79% rename from featuremanagement/appinsights/__init__.py rename to featuremanagement/azuremonitor/__init__.py index 18368b3..2e82260 100644 --- a/featuremanagement/appinsights/__init__.py +++ b/featuremanagement/azuremonitor/__init__.py @@ -4,10 +4,10 @@ # license information. # ------------------------------------------------------------------------- -from ._send_telemetry_appinsights import send_telemetry, track_event +from ._send_telemetry import publish_telemetry, track_event __all__ = [ - "send_telemetry", + "publish_telemetry", "track_event", ] diff --git a/featuremanagement/appinsights/_send_telemetry_appinsights.py b/featuremanagement/azuremonitor/_send_telemetry.py similarity index 82% rename from featuremanagement/appinsights/_send_telemetry_appinsights.py rename to featuremanagement/azuremonitor/_send_telemetry.py index 2884eea..f2418ea 100644 --- a/featuremanagement/appinsights/_send_telemetry_appinsights.py +++ b/featuremanagement/azuremonitor/_send_telemetry.py @@ -7,7 +7,7 @@ from .._models import VaraintAssignmentReason try: - from azure.monitor.events.extension import track_event as appinsights_track_event + from azure.monitor.events.extension import track_event as azure_monitor_track_event HAS_AZURE_MONITOR_EVENTS_EXTENSION = True except ImportError: @@ -24,6 +24,7 @@ EVENT_NAME = "FeatureEvaluation" + def track_event(event_name, user, event_properties=None): """ Track an event with the specified name and properties. @@ -36,11 +37,12 @@ def track_event(event_name, user, event_properties=None): if event_properties is None: event_properties = {} event_properties[TARGETING_ID] = user - appinsights_track_event(event_name, event_properties) + azure_monitor_track_event(event_name, event_properties) + -def send_telemetry(evaluation_event): +def publish_telemetry(evaluation_event): """ - Send telemetry for feature evaluation events. + Publishes the telemetry for a feature's evaluation event. """ if not HAS_AZURE_MONITOR_EVENTS_EXTENSION: return @@ -53,8 +55,8 @@ def send_telemetry(evaluation_event): if evaluation_event.reason and evaluation_event.reason != VaraintAssignmentReason.NONE: event[VARIANT] = evaluation_event.variant.name event[REASON] = evaluation_event.reason.value - - event["ETag"] = evaluation_event.feature.telemetry.metadata.get("etag") - event["FeatureFlagReference"] = evaluation_event.feature.telemetry.metadata.get("feature_flag_reference") - event["FeatureFlagId"] = evaluation_event.feature.telemetry.metadata.get("feature_flag_id") - appinsights_track_event(EVENT_NAME, event) + + event["ETag"] = evaluation_event.feature.telemetry.metadata.get("etag", "") + event["FeatureFlagReference"] = evaluation_event.feature.telemetry.metadata.get("feature_flag_reference", "") + event["FeatureFlagId"] = evaluation_event.feature.telemetry.metadata.get("feature_flag_id", "") + azure_monitor_track_event(EVENT_NAME, event) diff --git a/samples/feature_variant_sample_with_telemetry.py b/samples/feature_variant_sample_with_telemetry.py index d627a2d..2701e23 100644 --- a/samples/feature_variant_sample_with_telemetry.py +++ b/samples/feature_variant_sample_with_telemetry.py @@ -9,7 +9,7 @@ import sys from random_filter import RandomFilter from featuremanagement import FeatureManager -from featuremanagement.appinsights import send_telemetry_appinsights +from featuremanagement.azuremonitor import send_telemetry_appinsights try: from azure.monitor.opentelemetry import configure_azure_monitor diff --git a/tests/test_default_feature_flag_from_file.py b/tests/test_default_feature_flag_from_file.py index 686662f..bbdbb2f 100644 --- a/tests/test_default_feature_flag_from_file.py +++ b/tests/test_default_feature_flag_from_file.py @@ -5,7 +5,7 @@ # -------------------------------------------------------------------------- import json import unittest -from featuremanagement import FeatureManager +from featuremanagement import FeatureManager, TargetingContext class TestFeatureFlagFile(unittest.TestCase): @@ -36,48 +36,48 @@ def test_single_filters(self): assert feature_manager.is_enabled("Zeta") # Feature Flag with Targeting filter, Adam is not part of the default rollout. - assert not feature_manager.is_enabled("Eta", user="Adam") + assert not feature_manager.is_enabled("Eta", "Adam") # Feature Flag with Targeting filter, Ellie is part of the default rollout. - assert feature_manager.is_enabled("Eta", user="Ellie") + assert feature_manager.is_enabled("Eta", "Ellie") # Feature Flag with Targeting filter, Alice is a targeted user. - assert feature_manager.is_enabled("Eta", user="Alice") + assert feature_manager.is_enabled("Eta", "Alice") # Feature Flag with Targeting filter, Stage1 group is 100% targeted. - assert feature_manager.is_enabled("Eta", user="Adam", groups=["Stage1"]) + assert feature_manager.is_enabled("Eta", TargetingContext(user_id="Adam", groups=["Stage1"])) # Feature Flag with Targeting filter, Stage2 group is 50% targeted. - assert feature_manager.is_enabled("Eta", groups=["Stage2"]) + assert feature_manager.is_enabled("Eta", TargetingContext(groups=["Stage2"])) # Feature Flag with Targeting filter, Adam is enabled when part of Stage2 group. - assert feature_manager.is_enabled("Eta", user="Adam", groups=["Stage2"]) + assert feature_manager.is_enabled("Eta", TargetingContext(user_id="Adam", groups=["Stage2"])) # Feature Flag with Targeting filter, Chad is not part of the Stage2 group rollout nor the default rollout. - assert not feature_manager.is_enabled("Eta", user="Chad", groups=["Stage2"]) + assert not feature_manager.is_enabled("Eta", TargetingContext(user_id="Chad", groups=["Stage2"])) # Feature Flag with Targeting filter, Stage 3 group is excluded. - assert not feature_manager.is_enabled("Eta", groups=["Stage3"]) + assert not feature_manager.is_enabled("Eta", TargetingContext(groups=["Stage3"])) # Feature Flag with Targeting filter, Alice is approved, but exlusion of group 3 overrides. - assert not feature_manager.is_enabled("Eta", user="Alice", groups=["Stage3"]) + assert not feature_manager.is_enabled("Eta", TargetingContext(user_id="Alice", groups=["Stage3"])) # Feature Flag with Targeting filter, Ellie is part of default rollout, but exlusion of group 3 overrides. - assert not feature_manager.is_enabled("Eta", user="Ellie", groups=["Stage3"]) + assert not feature_manager.is_enabled("Eta", TargetingContext(user_id="Ellie", groups=["Stage3"])) # Feature Flag with Targeting filter, Stage 1 is 100% rolled out, but Dave is excluded. - assert not feature_manager.is_enabled("Eta", user="Dave", groups=["Stage1"]) + assert not feature_manager.is_enabled("Eta", TargetingContext(user_id="Dave", groups=["Stage1"])) # Feature Flag with Targeting filter, with just 50% rollout. Adama is not part of the 50%, # Brittney is not, group isn't part of the rollout so value isn't changed. - assert feature_manager.is_enabled("Theta", user="Adam") - assert feature_manager.is_enabled("Theta", user="Adam", groups=["Stage1"]) - assert feature_manager.is_enabled("Theta", user="Adam", groups=["Stage2"]) - assert feature_manager.is_enabled("Theta", user="Adam", groups=["Stage3"]) - assert not feature_manager.is_enabled("Theta", user="Brittney") - assert not feature_manager.is_enabled("Theta", user="Brittney", groups=["Stage1"]) - assert not feature_manager.is_enabled("Theta", user="Brittney", groups=["Stage2"]) - assert not feature_manager.is_enabled("Theta", user="Brittney", groups=["Stage3"]) + assert feature_manager.is_enabled("Theta", "Adam") + assert feature_manager.is_enabled("Theta", TargetingContext(user_id="Adam", groups=["Stage1"])) + assert feature_manager.is_enabled("Theta", TargetingContext(user_id="Adam", groups=["Stage2"])) + assert feature_manager.is_enabled("Theta", TargetingContext(user_id="Adam", groups=["Stage3"])) + assert not feature_manager.is_enabled("Theta", "Brittney") + assert not feature_manager.is_enabled("Theta", TargetingContext(user_id="Brittney", groups=["Stage1"])) + assert not feature_manager.is_enabled("Theta", TargetingContext(user_id="Brittney", groups=["Stage2"])) + assert not feature_manager.is_enabled("Theta", TargetingContext(user_id="Brittney", groups=["Stage3"])) # method: is_enabled def test_requirement_type_any(self): @@ -109,7 +109,7 @@ def test_requirement_type_all(self): # Feature Flag with two feature filters with the All requirement type, # the first is true, second is false, so the flag is disabled. - assert not feature_manager.is_enabled("Nu", user="Adam") + assert not feature_manager.is_enabled("Nu", "Adam") # Feature Flag with two feature filters with the All requirement type, both are true, so the flag is enabled. - assert feature_manager.is_enabled("Xi", user="Adam") + assert feature_manager.is_enabled("Xi", "Adam") diff --git a/tests/test_default_feature_flags.py b/tests/test_default_feature_flags.py index 080c255..225a2b4 100644 --- a/tests/test_default_feature_flags.py +++ b/tests/test_default_feature_flags.py @@ -5,7 +5,7 @@ # -------------------------------------------------------------------------- import unittest import pytest -from featuremanagement import FeatureManager +from featuremanagement import FeatureManager, TargetingContext class TestDefaultfeatureFlags(unittest.TestCase): @@ -43,13 +43,13 @@ def test_feature_manager_creation_with_targeting(self): feature_manager = FeatureManager(feature_flags) assert feature_manager is not None # Adam is in the user audience - assert feature_manager.is_enabled("Target", user="Adam") + assert feature_manager.is_enabled("Target", "Adam") # Brian is not part of the 50% or default 50% of users - assert not feature_manager.is_enabled("Target", user="Belle") + assert not feature_manager.is_enabled("Target", "Belle") # Brian is enabled because all of Stage 1 is enabled - assert feature_manager.is_enabled("Target", user="Belle", groups=["Stage1"]) + assert feature_manager.is_enabled("Target", TargetingContext(user_id="Belle", groups=["Stage1"])) # Brian is not enabled because he is not in Stage 2, group isn't looked at when user is targeted - assert not feature_manager.is_enabled("Target", user="Belle", groups=["Stage2"]) + assert not feature_manager.is_enabled("Target", TargetingContext(user_id="Belle", groups=["Stage2"])) # method: feature_manager_creation def test_feature_manager_creation_with_time_window(self): diff --git a/tests/test_feature_variants.py b/tests/test_feature_variants.py index c49a0ea..14c2b7e 100644 --- a/tests/test_feature_variants.py +++ b/tests/test_feature_variants.py @@ -3,7 +3,7 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # -------------------------------------------------------------------------- -from featuremanagement import FeatureManager, FeatureFilter +from featuremanagement import FeatureManager, FeatureFilter, TargetingContext class TestFeatureVariants: @@ -103,12 +103,12 @@ def test_basic_feature_variant_allocation_users(self): feature_manager = FeatureManager(feature_flags, feature_filters=[AlwaysOnFilter()]) assert feature_manager.is_enabled("Alpha") assert feature_manager.get_variant("Alpha") is None - assert not feature_manager.is_enabled("Alpha", user="Adam") - assert feature_manager.get_variant("Alpha", user="Adam").name == "On" - assert feature_manager.is_enabled("Alpha", user="Brittney") - assert feature_manager.get_variant("Alpha", user="Brittney").name == "Off" - assert feature_manager.is_enabled("Alpha", user="Charlie") - assert feature_manager.get_variant("Alpha", user="Charlie") is None + assert not feature_manager.is_enabled("Alpha", "Adam") + assert feature_manager.get_variant("Alpha", "Adam").name == "On" + assert feature_manager.is_enabled("Alpha", "Brittney") + assert feature_manager.get_variant("Alpha", "Brittney").name == "Off" + assert feature_manager.is_enabled("Alpha", "Charlie") + assert feature_manager.get_variant("Alpha", "Charlie") is None # method: is_enabled def test_basic_feature_variant_allocation_groups(self): @@ -143,12 +143,14 @@ def test_basic_feature_variant_allocation_groups(self): feature_manager = FeatureManager(feature_flags, feature_filters=[AlwaysOnFilter()]) assert feature_manager.is_enabled("Alpha") assert feature_manager.get_variant("Alpha") is None - assert not feature_manager.is_enabled("Alpha", user="Adam", groups=["Group1"]) - assert feature_manager.get_variant("Alpha", user="Adam", groups=["Group1"]).name == "On" - assert feature_manager.is_enabled("Alpha", user="Brittney", groups=["Group2"]) - assert feature_manager.get_variant("Alpha", user="Brittney", groups=["Group2"]).name == "Off" - assert feature_manager.is_enabled("Alpha", user="Charlie", groups=["Group3"]) - assert feature_manager.get_variant("Alpha", user="Charlie", groups=["Group3"]) is None + assert not feature_manager.is_enabled("Alpha", TargetingContext(user_id="Adam", groups=["Group1"])) + assert feature_manager.get_variant("Alpha", TargetingContext(user_id="Adam", groups=["Group1"])).name == "On" + assert feature_manager.is_enabled("Alpha", TargetingContext(user_id="Brittney", groups=["Group2"])) + assert ( + feature_manager.get_variant("Alpha", TargetingContext(user_id="Brittney", groups=["Group2"])).name == "Off" + ) + assert feature_manager.is_enabled("Alpha", TargetingContext(user_id="Charlie", groups=["Group3"])) + assert feature_manager.get_variant("Alpha", TargetingContext(user_id="Charlie", groups=["Group3"])) is None # method: is_enabled def test_basic_feature_variant_allocation_percentile(self): @@ -183,14 +185,16 @@ def test_basic_feature_variant_allocation_percentile(self): feature_manager = FeatureManager(feature_flags, feature_filters=[AlwaysOnFilter()]) assert feature_manager.is_enabled("Alpha") assert feature_manager.get_variant("Alpha").name == "Off" - assert feature_manager.is_enabled("Alpha", user="Adam") - assert feature_manager.get_variant("Alpha", user="Adam").name == "Off" - assert not feature_manager.is_enabled("Alpha", user="Brittney") - assert feature_manager.get_variant("Alpha", user="Brittney").name == "On" - assert not feature_manager.is_enabled("Alpha", user="Brittney", groups=["Group1"]) - assert feature_manager.get_variant("Alpha", user="Brittney", groups=["Group1"]).name == "On" - assert feature_manager.is_enabled("Alpha", user="Cassidy") - assert feature_manager.get_variant("Alpha", user="Cassidy").name == "Off" + assert feature_manager.is_enabled("Alpha", "Adam") + assert feature_manager.get_variant("Alpha", "Adam").name == "Off" + assert not feature_manager.is_enabled("Alpha", "Brittney") + assert feature_manager.get_variant("Alpha", "Brittney").name == "On" + assert not feature_manager.is_enabled("Alpha", TargetingContext(user_id="Brittney", groups=["Group1"])) + assert ( + feature_manager.get_variant("Alpha", TargetingContext(user_id="Brittney", groups=["Group1"])).name == "On" + ) + assert feature_manager.is_enabled("Alpha", "Cassidy") + assert feature_manager.get_variant("Alpha", "Cassidy").name == "Off" # method: is_enabled def test_basic_feature_variant_allocation_percentile_seeded(self): @@ -226,16 +230,18 @@ def test_basic_feature_variant_allocation_percentile_seeded(self): feature_manager = FeatureManager(feature_flags, feature_filters=[AlwaysOnFilter()]) assert feature_manager.is_enabled("Alpha") assert feature_manager.get_variant("Alpha").name == "Off" - assert not feature_manager.is_enabled("Alpha", user="Allison") - assert feature_manager.get_variant("Alpha", user="Allison").name == "On" - assert feature_manager.is_enabled("Alpha", user="Bubbles") - assert feature_manager.get_variant("Alpha", user="Bubbles").name == "Off" - assert feature_manager.is_enabled("Alpha", user="Bubbles", groups=["Group1"]) - assert feature_manager.get_variant("Alpha", user="Bubbles", groups=["Group1"]).name == "Off" - assert feature_manager.is_enabled("Alpha", user="Cassidy") - assert feature_manager.get_variant("Alpha", user="Cassidy").name == "Off" - assert not feature_manager.is_enabled("Alpha", user="Dan") - assert feature_manager.get_variant("Alpha", user="Dan").name == "On" + assert not feature_manager.is_enabled("Alpha", "Allison") + assert feature_manager.get_variant("Alpha", "Allison").name == "On" + assert feature_manager.is_enabled("Alpha", "Bubbles") + assert feature_manager.get_variant("Alpha", "Bubbles").name == "Off" + assert feature_manager.is_enabled("Alpha", TargetingContext(user_id="Bubbles", groups=["Group1"])) + assert ( + feature_manager.get_variant("Alpha", TargetingContext(user_id="Bubbles", groups=["Group1"])).name == "Off" + ) + assert feature_manager.is_enabled("Alpha", "Cassidy") + assert feature_manager.get_variant("Alpha", "Cassidy").name == "Off" + assert not feature_manager.is_enabled("Alpha", "Dan") + assert feature_manager.get_variant("Alpha", "Dan").name == "On" class AlwaysOnFilter(FeatureFilter): diff --git a/tests/test_send_telemetry_appinsights.py b/tests/test_send_telemetry_appinsights.py index 5091048..b07fb0c 100644 --- a/tests/test_send_telemetry_appinsights.py +++ b/tests/test_send_telemetry_appinsights.py @@ -9,7 +9,8 @@ from importlib import reload from unittest.mock import patch import pytest -from featuremanagement import EvaluationEvent, send_telemetry_appinsights, FeatureFlag, Variant, VaraintAssignmentReason +from featuremanagement import EvaluationEvent, FeatureFlag, Variant, VaraintAssignmentReason +import featuremanagement.azuremonitor._send_telemetry @pytest.mark.usefixtures("caplog") @@ -23,17 +24,17 @@ def test_send_telemetry_appinsights(self): evaluation_event.enabled = True evaluation_event.user = "test_user" evaluation_event.variant = variant - evaluation_event.reason = VaraintAssignmentReason.NONE + evaluation_event.reason = VaraintAssignmentReason.DEFAULT_WHEN_DISABLED - with patch("featuremanagement._send_telemetry_appinsights.track_event") as mock_track_event: - send_telemetry_appinsights(evaluation_event) + with patch("featuremanagement.azuremonitor._send_telemetry.azure_monitor_track_event") as mock_track_event: + featuremanagement.azuremonitor._send_telemetry.publish_telemetry(evaluation_event) mock_track_event.assert_called_once() assert mock_track_event.call_args[0][0] == "FeatureEvaluation" assert mock_track_event.call_args[0][1]["FeatureName"] == "TestFeature" assert mock_track_event.call_args[0][1]["Enabled"] == "True" assert mock_track_event.call_args[0][1]["TargetingId"] == "test_user" assert mock_track_event.call_args[0][1]["Variant"] == "TestVariant" - assert mock_track_event.call_args[0][1]["Reason"] == "NONE" + assert mock_track_event.call_args[0][1]["VariantAssignmentReason"] == "DefaultWhenDisabled" def test_send_telemetry_appinsights_no_user(self): evaluation_event = EvaluationEvent() @@ -42,17 +43,17 @@ def test_send_telemetry_appinsights_no_user(self): evaluation_event.feature = feature_flag evaluation_event.enabled = False evaluation_event.variant = variant - evaluation_event.reason = VaraintAssignmentReason.NONE + evaluation_event.reason = VaraintAssignmentReason.DEFAULT_WHEN_DISABLED - with patch("featuremanagement._send_telemetry_appinsights.track_event") as mock_track_event: - send_telemetry_appinsights(evaluation_event) + with patch("featuremanagement.azuremonitor._send_telemetry.azure_monitor_track_event") as mock_track_event: + featuremanagement.azuremonitor._send_telemetry.publish_telemetry(evaluation_event) mock_track_event.assert_called_once() assert mock_track_event.call_args[0][0] == "FeatureEvaluation" assert mock_track_event.call_args[0][1]["FeatureName"] == "TestFeature" assert mock_track_event.call_args[0][1]["Enabled"] == "False" assert "TargetingId" not in mock_track_event.call_args[0][1] assert mock_track_event.call_args[0][1]["Variant"] == "TestVariant" - assert mock_track_event.call_args[0][1]["Reason"] == "NONE" + assert mock_track_event.call_args[0][1]["VariantAssignmentReason"] == "DefaultWhenDisabled" def test_send_telemetry_appinsights_no_variant(self): evaluation_event = EvaluationEvent() @@ -61,8 +62,8 @@ def test_send_telemetry_appinsights_no_variant(self): evaluation_event.enabled = True evaluation_event.user = "test_user" - with patch("featuremanagement._send_telemetry_appinsights.track_event") as mock_track_event: - send_telemetry_appinsights(evaluation_event) + with patch("featuremanagement.azuremonitor._send_telemetry.azure_monitor_track_event") as mock_track_event: + featuremanagement.azuremonitor._send_telemetry.publish_telemetry(evaluation_event) mock_track_event.assert_called_once() assert mock_track_event.call_args[0][0] == "FeatureEvaluation" assert mock_track_event.call_args[0][1]["FeatureName"] == "TestFeature" @@ -78,7 +79,7 @@ def test_send_telemetry_appinsights_no_import(self, caplog): evaluation_event.enabled = True with patch.dict("sys.modules", {"azure.monitor.events.extension": None}): - reload(sys.modules["featuremanagement._send_telemetry_appinsights"]) + reload(sys.modules["featuremanagement.azuremonitor._send_telemetry"]) caplog.set_level(logging.WARNING) - send_telemetry_appinsights(evaluation_event) + featuremanagement.azuremonitor._send_telemetry.publish_telemetry(evaluation_event) assert "Telemetry will not be sent to Application Insights." in caplog.text From 089268f2ba0b5063fb86bf746447cf693351ed08 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Wed, 17 Apr 2024 12:53:30 -0700 Subject: [PATCH 04/35] formatting, updating samples --- featuremanagement/aio/_featuremanager.py | 2 +- samples/feature_flag_sample.py | 4 ++-- ...lag_with_azure_app_configuration_sample.py | 4 ++-- samples/feature_variant_sample.py | 8 ++++---- .../feature_variant_sample_with_telemetry.py | 8 +++++--- tests/test_send_telemetry_appinsights.py | 19 +++++++++++++++---- 6 files changed, 29 insertions(+), 16 deletions(-) diff --git a/featuremanagement/aio/_featuremanager.py b/featuremanagement/aio/_featuremanager.py index 35308d6..d250ed7 100644 --- a/featuremanagement/aio/_featuremanager.py +++ b/featuremanagement/aio/_featuremanager.py @@ -17,7 +17,7 @@ _get_feature_flag, _list_feature_flag_names, ) -from .._models import FeatureFlag, Variant, EvaluationEvent, VaraintAssignmentReason +from .._models import Variant, EvaluationEvent, VaraintAssignmentReason class FeatureManager: diff --git a/samples/feature_flag_sample.py b/samples/feature_flag_sample.py index 2e82bae..0cc77fd 100644 --- a/samples/feature_flag_sample.py +++ b/samples/feature_flag_sample.py @@ -30,5 +30,5 @@ # Is true Before 06-28-2023 print("Epsilon is ", feature_manager.is_enabled("Epsilon")) # Target is true for Adam, group Stage 1, and 50% of users -print("Target is ", feature_manager.is_enabled("Target", user="Adam")) -print("Target is ", feature_manager.is_enabled("Target", user="Brian")) +print("Target is ", feature_manager.is_enabled("Target", "Adam")) +print("Target is ", feature_manager.is_enabled("Target", "Brian")) diff --git a/samples/feature_flag_with_azure_app_configuration_sample.py b/samples/feature_flag_with_azure_app_configuration_sample.py index ce040d1..f3be579 100644 --- a/samples/feature_flag_with_azure_app_configuration_sample.py +++ b/samples/feature_flag_with_azure_app_configuration_sample.py @@ -41,6 +41,6 @@ def check_for_changes(): # Is true Before 06-28-2023 print("Epsilon is ", feature_manager.is_enabled("Epsilon")) # Target is true for Adam, group Stage 1, and 50% of users -print("Target is ", feature_manager.is_enabled("Target", user="Adam")) -print("Target is ", feature_manager.is_enabled("Target", user="Brian")) +print("Target is ", feature_manager.is_enabled("Target", "Adam")) +print("Target is ", feature_manager.is_enabled("Target", "Brian")) check_for_changes() diff --git a/samples/feature_variant_sample.py b/samples/feature_variant_sample.py index d68e119..eca33e3 100644 --- a/samples/feature_variant_sample.py +++ b/samples/feature_variant_sample.py @@ -18,8 +18,8 @@ feature_manager = FeatureManager(feature_flags, feature_filters=[RandomFilter()]) -print(feature_manager.is_enabled("TestVariants", user="Adam")) -print(feature_manager.get_variant("TestVariants", user="Adam").configuration) +print(feature_manager.is_enabled("TestVariants", "Adam")) +print(feature_manager.get_variant("TestVariants", "Adam").configuration) -print(feature_manager.is_enabled("TestVariants", user="Cass")) -print(feature_manager.get_variant("TestVariants", user="Cass").configuration) +print(feature_manager.is_enabled("TestVariants", "Cass")) +print(feature_manager.get_variant("TestVariants", "Cass").configuration) diff --git a/samples/feature_variant_sample_with_telemetry.py b/samples/feature_variant_sample_with_telemetry.py index 2701e23..79253bd 100644 --- a/samples/feature_variant_sample_with_telemetry.py +++ b/samples/feature_variant_sample_with_telemetry.py @@ -9,7 +9,7 @@ import sys from random_filter import RandomFilter from featuremanagement import FeatureManager -from featuremanagement.azuremonitor import send_telemetry_appinsights +from featuremanagement.azuremonitor import publish_telemetry try: from azure.monitor.opentelemetry import configure_azure_monitor @@ -25,7 +25,9 @@ feature_flags = json.load(f) # Initialize the feature manager with telemetry callback -feature_manager = FeatureManager(feature_flags, feature_filters=[RandomFilter()]) +feature_manager = FeatureManager( + feature_flags, feature_filters=[RandomFilter()], on_feature_evaluated=publish_telemetry +) # Evaluate the feature flag for the user -print(feature_manager.get_variant("TestVariants", user="Adam", telemetry=send_telemetry_appinsights).configuration) +print(feature_manager.get_variant("TestVariants", "Adam").configuration) diff --git a/tests/test_send_telemetry_appinsights.py b/tests/test_send_telemetry_appinsights.py index b07fb0c..e0ee6db 100644 --- a/tests/test_send_telemetry_appinsights.py +++ b/tests/test_send_telemetry_appinsights.py @@ -27,7 +27,10 @@ def test_send_telemetry_appinsights(self): evaluation_event.reason = VaraintAssignmentReason.DEFAULT_WHEN_DISABLED with patch("featuremanagement.azuremonitor._send_telemetry.azure_monitor_track_event") as mock_track_event: - featuremanagement.azuremonitor._send_telemetry.publish_telemetry(evaluation_event) + # This is called like this so we can override the track_event function + featuremanagement.azuremonitor._send_telemetry.publish_telemetry( + evaluation_event + ) # pylint: disable=protected-access mock_track_event.assert_called_once() assert mock_track_event.call_args[0][0] == "FeatureEvaluation" assert mock_track_event.call_args[0][1]["FeatureName"] == "TestFeature" @@ -46,7 +49,10 @@ def test_send_telemetry_appinsights_no_user(self): evaluation_event.reason = VaraintAssignmentReason.DEFAULT_WHEN_DISABLED with patch("featuremanagement.azuremonitor._send_telemetry.azure_monitor_track_event") as mock_track_event: - featuremanagement.azuremonitor._send_telemetry.publish_telemetry(evaluation_event) + # This is called like this so we can override the track_event function + featuremanagement.azuremonitor._send_telemetry.publish_telemetry( + evaluation_event + ) # pylint: disable=protected-access mock_track_event.assert_called_once() assert mock_track_event.call_args[0][0] == "FeatureEvaluation" assert mock_track_event.call_args[0][1]["FeatureName"] == "TestFeature" @@ -63,7 +69,10 @@ def test_send_telemetry_appinsights_no_variant(self): evaluation_event.user = "test_user" with patch("featuremanagement.azuremonitor._send_telemetry.azure_monitor_track_event") as mock_track_event: - featuremanagement.azuremonitor._send_telemetry.publish_telemetry(evaluation_event) + # This is called like this so we can override the track_event function + featuremanagement.azuremonitor._send_telemetry.publish_telemetry( + evaluation_event + ) # pylint: disable=protected-access mock_track_event.assert_called_once() assert mock_track_event.call_args[0][0] == "FeatureEvaluation" assert mock_track_event.call_args[0][1]["FeatureName"] == "TestFeature" @@ -81,5 +90,7 @@ def test_send_telemetry_appinsights_no_import(self, caplog): with patch.dict("sys.modules", {"azure.monitor.events.extension": None}): reload(sys.modules["featuremanagement.azuremonitor._send_telemetry"]) caplog.set_level(logging.WARNING) - featuremanagement.azuremonitor._send_telemetry.publish_telemetry(evaluation_event) + featuremanagement.azuremonitor._send_telemetry.publish_telemetry( + evaluation_event + ) # pylint: disable=protected-access assert "Telemetry will not be sent to Application Insights." in caplog.text From 43e2c04ec9bd27d8c5ef1e4ef001fa73f3d922f4 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Wed, 17 Apr 2024 12:58:17 -0700 Subject: [PATCH 05/35] fixing merge issue --- tests/test_send_telemetry_appinsights.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_send_telemetry_appinsights.py b/tests/test_send_telemetry_appinsights.py index e0ee6db..eef4ac0 100644 --- a/tests/test_send_telemetry_appinsights.py +++ b/tests/test_send_telemetry_appinsights.py @@ -28,9 +28,9 @@ def test_send_telemetry_appinsights(self): with patch("featuremanagement.azuremonitor._send_telemetry.azure_monitor_track_event") as mock_track_event: # This is called like this so we can override the track_event function - featuremanagement.azuremonitor._send_telemetry.publish_telemetry( + featuremanagement.azuremonitor._send_telemetry.publish_telemetry( # pylint: disable=protected-access evaluation_event - ) # pylint: disable=protected-access + ) mock_track_event.assert_called_once() assert mock_track_event.call_args[0][0] == "FeatureEvaluation" assert mock_track_event.call_args[0][1]["FeatureName"] == "TestFeature" @@ -50,9 +50,9 @@ def test_send_telemetry_appinsights_no_user(self): with patch("featuremanagement.azuremonitor._send_telemetry.azure_monitor_track_event") as mock_track_event: # This is called like this so we can override the track_event function - featuremanagement.azuremonitor._send_telemetry.publish_telemetry( + featuremanagement.azuremonitor._send_telemetry.publish_telemetry( # pylint: disable=protected-access evaluation_event - ) # pylint: disable=protected-access + ) mock_track_event.assert_called_once() assert mock_track_event.call_args[0][0] == "FeatureEvaluation" assert mock_track_event.call_args[0][1]["FeatureName"] == "TestFeature" @@ -70,9 +70,9 @@ def test_send_telemetry_appinsights_no_variant(self): with patch("featuremanagement.azuremonitor._send_telemetry.azure_monitor_track_event") as mock_track_event: # This is called like this so we can override the track_event function - featuremanagement.azuremonitor._send_telemetry.publish_telemetry( + featuremanagement.azuremonitor._send_telemetry.publish_telemetry( # pylint: disable=protected-access evaluation_event - ) # pylint: disable=protected-access + ) mock_track_event.assert_called_once() assert mock_track_event.call_args[0][0] == "FeatureEvaluation" assert mock_track_event.call_args[0][1]["FeatureName"] == "TestFeature" @@ -90,7 +90,7 @@ def test_send_telemetry_appinsights_no_import(self, caplog): with patch.dict("sys.modules", {"azure.monitor.events.extension": None}): reload(sys.modules["featuremanagement.azuremonitor._send_telemetry"]) caplog.set_level(logging.WARNING) - featuremanagement.azuremonitor._send_telemetry.publish_telemetry( + featuremanagement.azuremonitor._send_telemetry.publish_telemetry( # pylint: disable=protected-access evaluation_event - ) # pylint: disable=protected-access + ) assert "Telemetry will not be sent to Application Insights." in caplog.text From 4219544b1f3f5a928f98855f11c7cded3dfd0570 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Wed, 17 Apr 2024 13:01:13 -0700 Subject: [PATCH 06/35] typing issue --- featuremanagement/_models/_targeting_context.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/featuremanagement/_models/_targeting_context.py b/featuremanagement/_models/_targeting_context.py index 3bd5086..f7ad613 100644 --- a/featuremanagement/_models/_targeting_context.py +++ b/featuremanagement/_models/_targeting_context.py @@ -4,7 +4,7 @@ # license information. # -------------------------------------------------------------------------- -from typing import NamedTuple +from typing import NamedTuple, List class TargetingContext(NamedTuple): @@ -16,4 +16,4 @@ class TargetingContext(NamedTuple): user_id: str = "" # The users groups - groups: list[str] = [] + groups: List[str] = [] From 545175a795fba956e169a13743667174778a1a72 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Fri, 3 May 2024 11:37:12 -0700 Subject: [PATCH 07/35] fixing test validations from changes --- tests/validation_tests/test_json_validations.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/validation_tests/test_json_validations.py b/tests/validation_tests/test_json_validations.py index 5cd128e..afb728b 100644 --- a/tests/validation_tests/test_json_validations.py +++ b/tests/validation_tests/test_json_validations.py @@ -7,7 +7,7 @@ import json import unittest from pytest import raises -from featuremanagement import FeatureManager +from featuremanagement import FeatureManager, TargetingContext FILE_PATH = "tests/validation_tests/" SAMPLE_JSON_KEY = ".sample.json" @@ -89,7 +89,7 @@ def run_tests(self, test_key): user = feature_flag_test[INPUTS_KEY].get(USER_KEY, None) groups = feature_flag_test[INPUTS_KEY].get(GROUPS_KEY, []) assert ( - feature_manager.is_enabled(feature_flag_test[FEATURE_FLAG_NAME_KEY], user=user, groups=groups) + feature_manager.is_enabled(feature_flag_test[FEATURE_FLAG_NAME_KEY], TargetingContext(user_id=user, groups=groups)) == expected_is_enabled_result ), failed_description else: From eb1c8c2f9810ca7449c5e65147ba402a0085d57e Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Fri, 10 May 2024 14:49:22 -0700 Subject: [PATCH 08/35] Update test_json_validations.py --- tests/validation_tests/test_json_validations.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/validation_tests/test_json_validations.py b/tests/validation_tests/test_json_validations.py index afb728b..b74d985 100644 --- a/tests/validation_tests/test_json_validations.py +++ b/tests/validation_tests/test_json_validations.py @@ -89,7 +89,9 @@ def run_tests(self, test_key): user = feature_flag_test[INPUTS_KEY].get(USER_KEY, None) groups = feature_flag_test[INPUTS_KEY].get(GROUPS_KEY, []) assert ( - feature_manager.is_enabled(feature_flag_test[FEATURE_FLAG_NAME_KEY], TargetingContext(user_id=user, groups=groups)) + feature_manager.is_enabled( + feature_flag_test[FEATURE_FLAG_NAME_KEY], TargetingContext(user_id=user, groups=groups) + ) == expected_is_enabled_result ), failed_description else: From 61d00228642c95f70c3218c4b149b549bd34bb3b Mon Sep 17 00:00:00 2001 From: Matthew Metcalf Date: Mon, 13 May 2024 10:07:22 -0700 Subject: [PATCH 09/35] Apply suggestions from code review Co-authored-by: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> --- featuremanagement/_models/_evaluation_event.py | 2 +- featuremanagement/_models/_telemetry.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/featuremanagement/_models/_evaluation_event.py b/featuremanagement/_models/_evaluation_event.py index 2c2569a..585a334 100644 --- a/featuremanagement/_models/_evaluation_event.py +++ b/featuremanagement/_models/_evaluation_event.py @@ -9,7 +9,7 @@ @dataclass class EvaluationEvent: """ - Represents an evaluation event + Represents a feature flag evaluation event """ def __init__(self, *, enabled=False, feature_flag=None): diff --git a/featuremanagement/_models/_telemetry.py b/featuremanagement/_models/_telemetry.py index 68b2874..4e12514 100644 --- a/featuremanagement/_models/_telemetry.py +++ b/featuremanagement/_models/_telemetry.py @@ -9,7 +9,7 @@ @dataclass class Telemetry: """ - Represents the telemetry for a feature flag + Represents the telemetry configuration for a feature flag """ enabled: bool = False From b1a2b253060ca4d98278203d4abca07484129343 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Mon, 13 May 2024 11:12:01 -0700 Subject: [PATCH 10/35] Updating doc strings --- featuremanagement/_defaultfilters.py | 20 +++---- featuremanagement/_featurefilters.py | 17 +++--- featuremanagement/_featuremanager.py | 59 ++++++++----------- featuremanagement/_models/_allocation.py | 51 ++++++++-------- .../_models/_evaluation_event.py | 4 +- .../_models/_feature_conditions.py | 17 +++--- featuremanagement/_models/_feature_flag.py | 40 ++++++------- featuremanagement/_models/_telemetry.py | 2 +- featuremanagement/_models/_variant.py | 10 ++-- .../_models/_variant_assignment_reason.py | 2 +- .../_models/_variant_reference.py | 21 ++++--- featuremanagement/aio/_defaultfilters.py | 18 +++--- featuremanagement/aio/_featurefilters.py | 15 +++-- featuremanagement/aio/_featuremanager.py | 36 +++++------ 14 files changed, 141 insertions(+), 171 deletions(-) diff --git a/featuremanagement/_defaultfilters.py b/featuremanagement/_defaultfilters.py index c4c4869..3ba26b3 100644 --- a/featuremanagement/_defaultfilters.py +++ b/featuremanagement/_defaultfilters.py @@ -35,23 +35,22 @@ class TargetingException(Exception): """ - Exception raised when the targeting filter is not configured correctly + Exception raised when the targeting filter is not configured correctly. """ @FeatureFilter.alias("Microsoft.TimeWindow") class TimeWindowFilter(FeatureFilter): """ - Feature Filter that determines if the current time is within the time window + Feature Filter that determines if the current time is within the time window. """ def evaluate(self, context, **kwargs): """ - Determine if the feature flag is enabled for the given context + Determine if the feature flag is enabled for the given context. - :keyword Mapping context: Mapping with the Start and End time for the feature flag - :paramtype context: Mapping - :return: True if the current time is within the time window + :keyword Mapping context: Mapping with the Start and End time for the feature flag. + :return: True if the current time is within the time window. :rtype: bool """ start = context.get(PARAMETERS_KEY, {}).get(START_KEY) @@ -72,7 +71,7 @@ def evaluate(self, context, **kwargs): @FeatureFilter.alias("Microsoft.Targeting") class TargetingFilter(FeatureFilter): """ - Feature Filter that determines if the user is targeted for the feature flag + Feature Filter that determines if the user is targeted for the feature flag. """ @staticmethod @@ -98,11 +97,10 @@ def _target_group(self, target_user, target_group, group, feature_flag_name): def evaluate(self, context, **kwargs): """ - Determine if the feature flag is enabled for the given context + Determine if the feature flag is enabled for the given context. - :keyword Mapping context: Context for evaluating the user/group - :paramtype context: Mapping - :return: True if the user is targeted for the feature flag + :keyword Mapping context: Context for evaluating the user/group. + :return: True if the user is targeted for the feature flag. :rtype: bool """ target_user = kwargs.get(TARGETED_USER_KEY, None) diff --git a/featuremanagement/_featurefilters.py b/featuremanagement/_featurefilters.py index 772e1f0..c2b8d3f 100644 --- a/featuremanagement/_featurefilters.py +++ b/featuremanagement/_featurefilters.py @@ -8,24 +8,23 @@ class FeatureFilter(ABC): """ - Parent class for all feature filters + Parent class for all feature filters. """ @abstractmethod def evaluate(self, context, **kwargs): """ - Determine if the feature flag is enabled for the given context + Determine if the feature flag is enabled for the given context. - :param Mapping context: Context for the feature flag - :paramtype context: Mapping + :param Mapping context: Context for the feature flag. """ @property def name(self): """ - Get the name of the filter + Get the name of the filter. - :return: Name of the filter, or alias if it exists + :return: Name of the filter, or alias if it exists. :rtype: str """ if hasattr(self, "_alias"): @@ -35,10 +34,10 @@ def name(self): @staticmethod def alias(alias): """ - Decorator to set the alias for the filter + Decorator to set the alias for the filter. - :param str alias: Alias for the filter - :return: Decorator + :param str alias: Alias for the filter. + :return: Decorator. :rtype: callable """ diff --git a/featuremanagement/_featuremanager.py b/featuremanagement/_featuremanager.py index 4b8fbf9..01fc260 100644 --- a/featuremanagement/_featuremanager.py +++ b/featuremanagement/_featuremanager.py @@ -40,7 +40,7 @@ def _get_feature_flag(configuration, feature_flag_name): def _list_feature_flag_names(configuration): """ - List of all feature flag names + List of all feature flag names. """ feature_flag_names = [] feature_management = configuration.get(FEATURE_MANAGEMENT_KEY) @@ -58,14 +58,11 @@ def _list_feature_flag_names(configuration): class FeatureManager: """ - Feature Manager that determines if a feature flag is enabled for the given context - - :param configuration: Configuration object - :type configuration: Mapping - :keyword feature_filters: Custom filters to be used for evaluating feature flags - :paramtype feature_filters: list[FeatureFilter] - :keyword on_feature_evaluated: Callback function to be called when a feature flag is evaluated - :paramtype on_feature_evaluated: Callable[EvaluationEvent] + Feature Manager that determines if a feature flag is enabled for the given context. + + :param Mapping configuration: Configuration object. + :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. """ def __init__(self, configuration, **kwargs): @@ -159,23 +156,20 @@ def _variant_name_to_variant(self, feature_flag, variant_name): @overload def is_enabled(self, feature_flag_id, user_id, **kwargs): """ - Determine if the feature flag is enabled for the given context + Determine if the feature flag is enabled for the given context. - :param str feature_flag_id: Name of the feature flag - :paramtype feature_flag_id: str - :param str user_id: User identifier - :paramtype user_id: str - :return: True if the feature flag is enabled for the given context + :param str feature_flag_id: Name of the feature flag. + :param str user_id: User identifier. + :return: True if the feature flag is enabled for the given context. :rtype: bool """ def is_enabled(self, feature_flag_id, *args, **kwargs): """ - Determine if the feature flag is enabled for the given context + Determine if the feature flag is enabled for the given context. - :param str feature_flag_id: Name of the feature flag - :paramtype feature_flag_id: str - :return: True if the feature flag is enabled for the given context + :param str feature_flag_id: Name of the feature flag. + :return: True if the feature flag is enabled for the given context. :rtype: bool """ targeting_context = TargetingContext() @@ -193,25 +187,21 @@ def is_enabled(self, feature_flag_id, *args, **kwargs): @overload def get_variant(self, feature_flag_id, user_id, **kwargs): """ - Determine the variant for the given context + Determine the variant for the given context. - :param str feature_flag_id: Name of the feature flag - :paramtype feature_flag_id: str - :param str user_id: User identifier - :paramtype user_id: str - :return: return: Variant instance + :param str feature_flag_id: Name of the feature flag. + :param str user_id: User identifier. + :return: return: Variant instance. :rtype: Variant """ def get_variant(self, feature_flag_id, *args, **kwargs): """ - Determine the variant for the given context + Determine the variant for the given context. :param str feature_flag_id: Name of the feature flag - :paramtype feature_flag_id: str - :kwyword targeting_context: Targeting context - :paramtype TargetingContext: TargetingContext - :return: Variant instance + :keyword TargetingContext targeting_context: Targeting context. + :return: Variant instance. :rtype: Variant """ targeting_context = TargetingContext() @@ -281,11 +271,10 @@ def _assign_allocation(self, feature_flag, evaluation_event, targeting_context): def _check_feature(self, feature_flag_id, targeting_context, **kwargs): """ - Determine if the feature flag is enabled for the given context + Determine if the feature flag is enabled for the given context. - :param str feature_flag_id: Name of the feature flag - :paramtype feature_flag_id: str - :return: True if the feature flag is enabled for the given context + :param str feature_flag_id: Name of the feature flag. + :return: True if the feature flag is enabled for the given context. :rtype: bool """ evaluation_event = EvaluationEvent(enabled=False) @@ -319,6 +308,6 @@ def _check_feature(self, feature_flag_id, targeting_context, **kwargs): def list_feature_flag_names(self): """ - List of all feature flag names + List of all feature flag names. """ return _list_feature_flag_names(self._configuration) diff --git a/featuremanagement/_models/_allocation.py b/featuremanagement/_models/_allocation.py index b46889c..7e8e454 100644 --- a/featuremanagement/_models/_allocation.py +++ b/featuremanagement/_models/_allocation.py @@ -9,7 +9,7 @@ class Allocation: """ - Represents an allocation + Represents an allocation configuration for a feature flag. """ def __init__(self, feature_name): @@ -23,7 +23,7 @@ def __init__(self, feature_name): @classmethod def convert_from_json(cls, json, feature_name): """ - Convert a JSON object to Allocation + Convert a JSON object to Allocation. :param json: JSON object :type json: dict @@ -56,9 +56,9 @@ def convert_from_json(cls, json, feature_name): @property def default_when_enabled(self): """ - Get the default variant when the feature flag is enabled + Get the default variant when the feature flag is enabled. - :return: Default variant when the feature flag is enabled + :return: Default variant when the feature flag is enabled. :rtype: str """ return self._default_when_enabled @@ -66,9 +66,9 @@ def default_when_enabled(self): @property def default_when_disabled(self): """ - Get the default variant when the feature flag is disabled + Get the default variant when the feature flag is disabled. - :return: Default variant when the feature flag is disabled + :return: Default variant when the feature flag is disabled. :rtype: str """ return self._default_when_disabled @@ -76,9 +76,9 @@ def default_when_disabled(self): @property def user(self): """ - Get the user allocations + Get the user allocations. - :return: User allocations + :return: User allocations. :rtype: list[UserAllocation] """ return self._user @@ -86,9 +86,9 @@ def user(self): @property def group(self): """ - Get the group allocations + Get the group allocations. - :return: Group allocations + :return: Group allocations. :rtype: list[GroupAllocation] """ return self._group @@ -96,9 +96,9 @@ def group(self): @property def percentile(self): """ - Get the percentile allocations + Get the percentile allocations. - :return: Percentile allocations + :return: Percentile allocations. :rtype: list[PercentileAllocation] """ return self._percentile @@ -106,9 +106,9 @@ def percentile(self): @property def seed(self): """ - Get the seed for the allocation + Get the seed for the allocation. - :return: Seed for the allocation + :return: Seed for the allocation. :rtype: str """ return self._seed @@ -117,7 +117,7 @@ def seed(self): @dataclass class UserAllocation: """ - Represents a user allocation + Represents a user allocation. """ variant: str @@ -127,7 +127,7 @@ class UserAllocation: @dataclass class GroupAllocation: """ - Represents a group allocation + Represents a group allocation. """ variant: str @@ -136,7 +136,7 @@ class GroupAllocation: class PercentileAllocation: """ - Represents a percentile allocation + Represents a percentile allocation. """ def __init__(self): @@ -147,10 +147,9 @@ def __init__(self): @classmethod def convert_from_json(cls, json): """ - Convert a JSON object to PercentileAllocation + Convert a JSON object to PercentileAllocation. - :param json: JSON object - :type json: dict + :param dict json: JSON object. :return: PercentileAllocation :rtype: PercentileAllocation """ @@ -165,9 +164,9 @@ def convert_from_json(cls, json): @property def variant(self): """ - Get the variant for the allocation + Get the variant for the allocation. - :return: Variant for the allocation + :return: Variant for the allocation. :rtype: str """ return self._variant @@ -175,9 +174,9 @@ def variant(self): @property def percentile_from(self): """ - Get the starting percentile for the allocation + Get the starting percentile for the allocation. - :return: Starting percentile for the allocation + :return: Starting percentile for the allocation. :rtype: int """ return self._percentile_from @@ -185,9 +184,9 @@ def percentile_from(self): @property def percentile_to(self): """ - Get the ending percentile for the allocation + Get the ending percentile for the allocation. - :return: Ending percentile for the allocation + :return: Ending percentile for the allocation. :rtype: int """ return self._percentile_to diff --git a/featuremanagement/_models/_evaluation_event.py b/featuremanagement/_models/_evaluation_event.py index 585a334..e8a497a 100644 --- a/featuremanagement/_models/_evaluation_event.py +++ b/featuremanagement/_models/_evaluation_event.py @@ -9,12 +9,12 @@ @dataclass class EvaluationEvent: """ - Represents a feature flag evaluation event + Represents a feature flag evaluation event. """ def __init__(self, *, enabled=False, feature_flag=None): """ - Initialize the EvaluationEvent + Initialize the EvaluationEvent. """ self.feature = feature_flag self.user = "" diff --git a/featuremanagement/_models/_feature_conditions.py b/featuremanagement/_models/_feature_conditions.py index 9e6ab54..275abd4 100644 --- a/featuremanagement/_models/_feature_conditions.py +++ b/featuremanagement/_models/_feature_conditions.py @@ -15,7 +15,7 @@ class FeatureConditions: """ - Represents the conditions for a feature flag + Represents the conditions for a feature flag. """ def __init__(self): @@ -25,11 +25,10 @@ def __init__(self): @classmethod def convert_from_json(cls, feature_name, json_value): """ - Convert a JSON object to FeatureConditions + Convert a JSON object to FeatureConditions. - :param json: JSON object - :type json: dict - :return: FeatureConditions + :param dict json: JSON object. + :return: FeatureConditions. :rtype: FeatureConditions """ conditions = cls() @@ -46,9 +45,9 @@ def convert_from_json(cls, feature_name, json_value): @property def requirement_type(self): """ - Get the requirement type for the feature flag + Get the requirement type for the feature flag. - :return: Requirement type + :return: Requirement type. :rtype: str """ return self._requirement_type @@ -56,9 +55,9 @@ def requirement_type(self): @property def client_filters(self): """ - Get the client filters for the feature flag + Get the client filters for the feature flag. - :return: Client filters + :return: Client filters. :rtype: list[dict] """ return self._client_filters diff --git a/featuremanagement/_models/_feature_flag.py b/featuremanagement/_models/_feature_flag.py index b0e566d..7a50336 100644 --- a/featuremanagement/_models/_feature_flag.py +++ b/featuremanagement/_models/_feature_flag.py @@ -18,7 +18,7 @@ class FeatureFlag: """ - Represents a feature flag + Represents a feature flag. """ def __init__(self): @@ -32,11 +32,10 @@ def __init__(self): @classmethod def convert_from_json(cls, json_value): """ - Convert a JSON object to FeatureFlag + Convert a JSON object to FeatureFlag. - :param json_value: JSON object - :type json_value: dict - :return: FeatureFlag + :param dict json_value: JSON object + :return: FeatureFlag. :rtype: FeatureFlag """ feature_flag = cls() @@ -70,9 +69,9 @@ def convert_from_json(cls, json_value): @property def name(self): """ - Get the name of the feature flag + Get the name of the feature flag. - :return: Name of the feature flag + :return: Name of the feature flag. :rtype: str """ return self._id @@ -80,9 +79,9 @@ def name(self): @property def enabled(self): """ - Get the status of the feature flag + Get the status of the feature flag. - :return: Status of the feature flag + :return: Status of the feature flag. :rtype: bool """ return self._enabled @@ -90,9 +89,9 @@ def enabled(self): @property def conditions(self): """ - Get the conditions for the feature flag + Get the conditions for the feature flag. - :return: Conditions for the feature flag + :return: Conditions for the feature flag. :rtype: FeatureConditions """ return self._conditions @@ -100,9 +99,9 @@ def conditions(self): @property def allocation(self): """ - Get the allocation for the feature flag + Get the allocation for the feature flag. - :return: Allocation for the feature flag + :return: Allocation for the feature flag. :rtype: Allocation """ return self._allocation @@ -110,9 +109,9 @@ def allocation(self): @property def variants(self): """ - Get the variants for the feature flag + Get the variants for the feature flag. - :return: Variants for the feature flag + :return: Variants for the feature flag. :rtype: list[VariantReference] """ return self._variants @@ -120,9 +119,9 @@ def variants(self): @property def telemetry(self): """ - Get the telemetry for the feature flag + Get the telemetry configuration for the feature flag. - :return: Telemetry for the feature flag + :return: Telemetry for the feature flag. :rtype: Telemetry """ return self._telemetry @@ -137,11 +136,10 @@ def _validate(self): def _convert_boolean_value(enabled): """ - Convert the value to a boolean if it is a string + Convert the value to a boolean if it is a string. - :param enabled: Value to be converted - :type enabled: str or bool - :return: Converted value + :param Union[str, bool] enabled: Value to be converted. + :return: Converted value. :rtype: bool """ if isinstance(enabled, bool): diff --git a/featuremanagement/_models/_telemetry.py b/featuremanagement/_models/_telemetry.py index 4e12514..55e3917 100644 --- a/featuremanagement/_models/_telemetry.py +++ b/featuremanagement/_models/_telemetry.py @@ -9,7 +9,7 @@ @dataclass class Telemetry: """ - Represents the telemetry configuration for a feature flag + Represents the telemetry configuration for a feature flag. """ enabled: bool = False diff --git a/featuremanagement/_models/_variant.py b/featuremanagement/_models/_variant.py index 25f692c..8b489e1 100644 --- a/featuremanagement/_models/_variant.py +++ b/featuremanagement/_models/_variant.py @@ -9,10 +9,8 @@ class Variant: """ A class representing a variant configuration assigned by a feature flag. - :param name: The name of the variant - :type name: str - :param configuration: The configuration of the variant - :type configuration: dict + :param str name: The name of the variant + :param dict configuration: The configuration of the variant. """ def __init__(self, name, configuration): @@ -22,7 +20,7 @@ def __init__(self, name, configuration): @property def name(self): """ - The name of the variant + The name of the variant. :rtype: str """ return self._name @@ -30,7 +28,7 @@ def name(self): @property def configuration(self): """ - The configuration of the variant + The configuration of the variant. :rtype: dict """ return self._configuration diff --git a/featuremanagement/_models/_variant_assignment_reason.py b/featuremanagement/_models/_variant_assignment_reason.py index 38c53de..c444102 100644 --- a/featuremanagement/_models/_variant_assignment_reason.py +++ b/featuremanagement/_models/_variant_assignment_reason.py @@ -8,7 +8,7 @@ class VaraintAssignmentReason(Enum): """ - Represents an assignment reason + Represents an assignment reason. """ NONE = "NONE" diff --git a/featuremanagement/_models/_variant_reference.py b/featuremanagement/_models/_variant_reference.py index 6c74786..5742b07 100644 --- a/featuremanagement/_models/_variant_reference.py +++ b/featuremanagement/_models/_variant_reference.py @@ -10,7 +10,7 @@ @dataclass class VariantReference: """ - Represents a variant reference + Represents a variant reference. """ def __init__(self): @@ -22,10 +22,9 @@ def __init__(self): @classmethod def convert_from_json(cls, json): """ - Convert a JSON object to VariantReference + Convert a JSON object to VariantReference. - :param json: JSON object - :type json: dict + :param dict json: JSON object :return: VariantReference :rtype: VariantReference """ @@ -41,7 +40,7 @@ def convert_from_json(cls, json): @property def name(self): """ - Get the name of the variant + Get the name of the variant. :return: Name of the variant :rtype: str @@ -51,9 +50,9 @@ def name(self): @property def configuration_value(self): """ - Get the configuration value for the variant + Get the configuration value for the variant. - :return: Configuration value for the variant + :return: Configuration value for the variant. :rtype: str """ return self._configuration_value @@ -61,9 +60,9 @@ def configuration_value(self): @property def configuration_reference(self): """ - Get the configuration reference for the variant + Get the configuration reference for the variant. - :return: Configuration reference for the variant + :return: Configuration reference for the variant. :rtype: str """ return self._configuration_reference @@ -71,9 +70,9 @@ def configuration_reference(self): @property def status_override(self): """ - Get the status override for the variant + Get the status override for the variant. - :return: Status override for the variant + :return: Status override for the variant. :rtype: str """ return self._status_override diff --git a/featuremanagement/aio/_defaultfilters.py b/featuremanagement/aio/_defaultfilters.py index d826c6d..8c61020 100644 --- a/featuremanagement/aio/_defaultfilters.py +++ b/featuremanagement/aio/_defaultfilters.py @@ -14,7 +14,7 @@ @FeatureFilter.alias("Microsoft.TimeWindow") class TimeWindowFilter(FeatureFilter): """ - Feature Filter that determines if the current time is within the time window + Feature Filter that determines if the current time is within the time window. """ def __init__(self): @@ -22,11 +22,10 @@ def __init__(self): async def evaluate(self, context, **kwargs): """ - Determine if the feature flag is enabled for the given context + Determine if the feature flag is enabled for the given context. - :keyword Mapping context: Mapping with the Start and End time for the feature flag - :paramtype context: Mapping - :return: True if the current time is within the time window + :keyword Mapping context: Mapping with the Start and End time for the feature flag. + :return: True if the current time is within the time window. :rtype: bool """ return self._filter.evaluate(context, **kwargs) @@ -35,7 +34,7 @@ async def evaluate(self, context, **kwargs): @FeatureFilter.alias("Microsoft.Targeting") class TargetingFilter(FeatureFilter): """ - Feature Filter that determines if the user is targeted for the feature flag + Feature Filter that determines if the user is targeted for the feature flag. """ def __init__(self): @@ -43,11 +42,10 @@ def __init__(self): async def evaluate(self, context, **kwargs): """ - Determine if the feature flag is enabled for the given context + Determine if the feature flag is enabled for the given context. - :keyword Mapping context: Context for evaluating the user/group - :paramtype context: Mapping - :return: True if the user is targeted for the feature flag + :keyword Mapping context: Context for evaluating the user/group. + :return: True if the user is targeted for the feature flag. :rtype: bool """ return self._filter.evaluate(context, **kwargs) diff --git a/featuremanagement/aio/_featurefilters.py b/featuremanagement/aio/_featurefilters.py index fa4ffee..f384ccf 100644 --- a/featuremanagement/aio/_featurefilters.py +++ b/featuremanagement/aio/_featurefilters.py @@ -8,24 +8,23 @@ class FeatureFilter(ABC): """ - Parent class for all async feature filters + Parent class for all async feature filters. """ @abstractmethod async def evaluate(self, context, **kwargs): """ - Determine if the feature flag is enabled for the given context + Determine if the feature flag is enabled for the given context. - :param Mapping context: Context for the feature flag - :paramtype context: Mapping + :param Mapping context: Context for the feature flag. """ @property def name(self): """ - Get the name of the filter + Get the name of the filter. - :return: Name of the filter, or alias if it exists + :return: Name of the filter, or alias if it exists. :rtype: str """ if hasattr(self, "_alias"): @@ -35,9 +34,9 @@ def name(self): @staticmethod def alias(alias): """ - Decorator to set the alias for the filter + Decorator to set the alias for the filter. - :param str alias: Alias for the filter + :param str alias: Alias for the filter. :return: Decorator :rtype: callable """ diff --git a/featuremanagement/aio/_featuremanager.py b/featuremanagement/aio/_featuremanager.py index d250ed7..dadb862 100644 --- a/featuremanagement/aio/_featuremanager.py +++ b/featuremanagement/aio/_featuremanager.py @@ -22,14 +22,11 @@ class FeatureManager: """ - Feature Manager that determines if a feature flag is enabled for the given context - - :param configuration: Configuration object - :type configuration: Mapping - :keyword feature_filters: Custom filters to be used for evaluating feature flags - :paramtype feature_filters: list[FeatureFilter] - :keyword telemetry: Telemetry callback function - :paramtype telemetry: Callable[EvaluationEvent] + Feature Manager that determines if a feature flag is enabled for the given context. + + :param Mapping configuration: Configuration object. + :keyword list[FeatureFilter] feature_filters: Custom filters to be used for evaluating feature flags + :keyword Callable[EvaluationEvent] telemetry: Telemetry callback function """ def __init__(self, configuration, **kwargs): @@ -125,22 +122,20 @@ def _variant_name_to_variant(self, feature_flag, variant_name): async def is_enabled(self, feature_flag_id, **kwargs): """ - Determine if the feature flag is enabled for the given context + Determine if the feature flag is enabled for the given context. - :param str feature_flag_id: Name of the feature flag - :paramtype feature_flag_id: str - :return: True if the feature flag is enabled for the given context + :param str feature_flag_id: Name of the feature flag. + :return: True if the feature flag is enabled for the given context. :rtype: bool """ return (await self._check_feature(feature_flag_id, **kwargs)).enabled async def get_variant(self, feature_flag_id, **kwargs): """ - Determine the variant for the given context + Determine the variant for the given context. - :param str feature_flag_id: Name of the feature flag - :paramtype feature_flag_id: str - :return: Name of the variant + :param str feature_flag_id: Name of the feature flag. + :return: Name of the variant. :rtype: str """ result = await self._check_feature(feature_flag_id, **kwargs) @@ -206,11 +201,10 @@ def _assign_allocation(self, feature_flag, evaluation_event, **kwargs): async def _check_feature(self, feature_flag_id, **kwargs): """ - Determine if the feature flag is enabled for the given context + Determine if the feature flag is enabled for the given context. - :param str feature_flag_id: Name of the feature flag - :paramtype feature_flag_id: str - :return: True if the feature flag is enabled for the given context + :param str feature_flag_id: Name of the feature flag. + :return: True if the feature flag is enabled for the given context. :rtype: bool """ evaluation_event = EvaluationEvent(enabled=False) @@ -243,6 +237,6 @@ async def _check_feature(self, feature_flag_id, **kwargs): def list_feature_flag_names(self): """ - List of all feature flag names + List of all feature flag names. """ return _list_feature_flag_names(self._configuration) From 8025453aa7b16fad34ec189e4b9b3e5677a00f31 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Mon, 13 May 2024 11:14:25 -0700 Subject: [PATCH 11/35] Spelling --- featuremanagement/__init__.py | 4 ++-- featuremanagement/_featuremanager.py | 8 ++++---- featuremanagement/_models/__init__.py | 4 ++-- featuremanagement/_models/_variant_assignment_reason.py | 2 +- featuremanagement/aio/_featuremanager.py | 8 ++++---- featuremanagement/azuremonitor/_send_telemetry.py | 4 ++-- tests/test_send_telemetry_appinsights.py | 6 +++--- 7 files changed, 18 insertions(+), 18 deletions(-) diff --git a/featuremanagement/__init__.py b/featuremanagement/__init__.py index 80fbd9d..0a06a1c 100644 --- a/featuremanagement/__init__.py +++ b/featuremanagement/__init__.py @@ -6,7 +6,7 @@ from ._featuremanager import FeatureManager from ._featurefilters import FeatureFilter from ._defaultfilters import TimeWindowFilter, TargetingFilter -from ._models import FeatureFlag, Variant, EvaluationEvent, VaraintAssignmentReason, TargetingContext +from ._models import FeatureFlag, Variant, EvaluationEvent, VariantAssignmentReason, TargetingContext from ._version import VERSION @@ -19,6 +19,6 @@ "FeatureFlag", "Variant", "EvaluationEvent", - "VaraintAssignmentReason", + "VariantAssignmentReason", "TargetingContext", ] diff --git a/featuremanagement/_featuremanager.py b/featuremanagement/_featuremanager.py index 01fc260..b733c84 100644 --- a/featuremanagement/_featuremanager.py +++ b/featuremanagement/_featuremanager.py @@ -9,7 +9,7 @@ from typing import overload from ._defaultfilters import TimeWindowFilter, TargetingFilter from ._featurefilters import FeatureFilter -from ._models import FeatureFlag, Variant, EvaluationEvent, VaraintAssignmentReason, TargetingContext +from ._models import FeatureFlag, Variant, EvaluationEvent, VariantAssignmentReason, TargetingContext FEATURE_MANAGEMENT_KEY = "feature_management" @@ -123,13 +123,13 @@ def _assign_variant(self, feature_flag, targeting_context): if feature_flag.allocation.user and targeting_context.user_id: for user_allocation in feature_flag.allocation.user: if targeting_context.user_id in user_allocation.users: - evaluation_event.reason = VaraintAssignmentReason.USER + evaluation_event.reason = VariantAssignmentReason.USER return user_allocation.variant, evaluation_event if feature_flag.allocation.group and len(targeting_context.groups) > 0: for group_allocation in feature_flag.allocation.group: for group in targeting_context.groups: if group in group_allocation.groups: - evaluation_event.reason = VaraintAssignmentReason.GROUP + evaluation_event.reason = VariantAssignmentReason.GROUP return group_allocation.variant, evaluation_event if feature_flag.allocation.percentile: context_id = targeting_context.user_id + "\n" + feature_flag.allocation.seed @@ -138,7 +138,7 @@ def _assign_variant(self, feature_flag, targeting_context): if box == 100 and percentile_allocation.percentile_to == 100: return percentile_allocation.variant if percentile_allocation.percentile_from <= box < percentile_allocation.percentile_to: - evaluation_event.reason = VaraintAssignmentReason.PERCENTILE + evaluation_event.reason = VariantAssignmentReason.PERCENTILE return percentile_allocation.variant, evaluation_event return None, evaluation_event diff --git a/featuremanagement/_models/__init__.py b/featuremanagement/_models/__init__.py index bb5e88c..7e24214 100644 --- a/featuremanagement/_models/__init__.py +++ b/featuremanagement/_models/__init__.py @@ -6,9 +6,9 @@ from ._feature_flag import FeatureFlag from ._variant import Variant from ._evaluation_event import EvaluationEvent -from ._variant_assignment_reason import VaraintAssignmentReason +from ._variant_assignment_reason import VariantAssignmentReason from ._targeting_context import TargetingContext __path__ = __import__("pkgutil").extend_path(__path__, __name__) # type: ignore -__all__ = ["FeatureFlag", "Variant", "EvaluationEvent", "VaraintAssignmentReason", "TargetingContext"] +__all__ = ["FeatureFlag", "Variant", "EvaluationEvent", "VariantAssignmentReason", "TargetingContext"] diff --git a/featuremanagement/_models/_variant_assignment_reason.py b/featuremanagement/_models/_variant_assignment_reason.py index c444102..ffb55b1 100644 --- a/featuremanagement/_models/_variant_assignment_reason.py +++ b/featuremanagement/_models/_variant_assignment_reason.py @@ -6,7 +6,7 @@ from enum import Enum -class VaraintAssignmentReason(Enum): +class VariantAssignmentReason(Enum): """ Represents an assignment reason. """ diff --git a/featuremanagement/aio/_featuremanager.py b/featuremanagement/aio/_featuremanager.py index dadb862..b16f86b 100644 --- a/featuremanagement/aio/_featuremanager.py +++ b/featuremanagement/aio/_featuremanager.py @@ -17,7 +17,7 @@ _get_feature_flag, _list_feature_flag_names, ) -from .._models import Variant, EvaluationEvent, VaraintAssignmentReason +from .._models import Variant, EvaluationEvent, VariantAssignmentReason class FeatureManager: @@ -89,13 +89,13 @@ def _assign_variant(self, feature_flag, **kwargs): if feature_flag.allocation.user and user: for user_allocation in feature_flag.allocation.user: if user in user_allocation.users: - evaluation_event.reason = VaraintAssignmentReason.USER + evaluation_event.reason = VariantAssignmentReason.USER return user_allocation.variant, evaluation_event if feature_flag.allocation.group and groups: for group_allocation in feature_flag.allocation.group: for group in groups: if group in group_allocation.groups: - evaluation_event.reason = VaraintAssignmentReason.GROUP + evaluation_event.reason = VariantAssignmentReason.GROUP return group_allocation.variant, evaluation_event if feature_flag.allocation.percentile: user = kwargs.get("user", "") @@ -105,7 +105,7 @@ def _assign_variant(self, feature_flag, **kwargs): if box == 100 and percentile_allocation.percentile_to == 100: return percentile_allocation.variant if percentile_allocation.percentile_from <= box < percentile_allocation.percentile_to: - evaluation_event.reason = VaraintAssignmentReason.PERCENTILE + evaluation_event.reason = VariantAssignmentReason.PERCENTILE return percentile_allocation.variant, evaluation_event return None, evaluation_event diff --git a/featuremanagement/azuremonitor/_send_telemetry.py b/featuremanagement/azuremonitor/_send_telemetry.py index f2418ea..2484b71 100644 --- a/featuremanagement/azuremonitor/_send_telemetry.py +++ b/featuremanagement/azuremonitor/_send_telemetry.py @@ -4,7 +4,7 @@ # license information. # -------------------------------------------------------------------------- import logging -from .._models import VaraintAssignmentReason +from .._models import VariantAssignmentReason try: from azure.monitor.events.extension import track_event as azure_monitor_track_event @@ -52,7 +52,7 @@ def publish_telemetry(evaluation_event): if evaluation_event.user: event[TARGETING_ID] = evaluation_event.user - if evaluation_event.reason and evaluation_event.reason != VaraintAssignmentReason.NONE: + if evaluation_event.reason and evaluation_event.reason != VariantAssignmentReason.NONE: event[VARIANT] = evaluation_event.variant.name event[REASON] = evaluation_event.reason.value diff --git a/tests/test_send_telemetry_appinsights.py b/tests/test_send_telemetry_appinsights.py index eef4ac0..4ddd5d2 100644 --- a/tests/test_send_telemetry_appinsights.py +++ b/tests/test_send_telemetry_appinsights.py @@ -9,7 +9,7 @@ from importlib import reload from unittest.mock import patch import pytest -from featuremanagement import EvaluationEvent, FeatureFlag, Variant, VaraintAssignmentReason +from featuremanagement import EvaluationEvent, FeatureFlag, Variant, VariantAssignmentReason import featuremanagement.azuremonitor._send_telemetry @@ -24,7 +24,7 @@ def test_send_telemetry_appinsights(self): evaluation_event.enabled = True evaluation_event.user = "test_user" evaluation_event.variant = variant - evaluation_event.reason = VaraintAssignmentReason.DEFAULT_WHEN_DISABLED + evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_DISABLED with patch("featuremanagement.azuremonitor._send_telemetry.azure_monitor_track_event") as mock_track_event: # This is called like this so we can override the track_event function @@ -46,7 +46,7 @@ def test_send_telemetry_appinsights_no_user(self): evaluation_event.feature = feature_flag evaluation_event.enabled = False evaluation_event.variant = variant - evaluation_event.reason = VaraintAssignmentReason.DEFAULT_WHEN_DISABLED + evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_DISABLED with patch("featuremanagement.azuremonitor._send_telemetry.azure_monitor_track_event") as mock_track_event: # This is called like this so we can override the track_event function From 06f953607cd9066822297f95a5479f86c42c0a63 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Mon, 13 May 2024 11:18:35 -0700 Subject: [PATCH 12/35] Updating async name. --- featuremanagement/aio/_featuremanager.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/featuremanagement/aio/_featuremanager.py b/featuremanagement/aio/_featuremanager.py index b16f86b..c881da9 100644 --- a/featuremanagement/aio/_featuremanager.py +++ b/featuremanagement/aio/_featuremanager.py @@ -26,7 +26,7 @@ class FeatureManager: :param Mapping configuration: Configuration object. :keyword list[FeatureFilter] feature_filters: Custom filters to be used for evaluating feature flags - :keyword Callable[EvaluationEvent] telemetry: Telemetry callback function + :keyword Callable[EvaluationEvent] on_feature_evaluated: Callback function to be called when a feature flag is evaluated. """ def __init__(self, configuration, **kwargs): @@ -36,7 +36,7 @@ def __init__(self, configuration, **kwargs): self._configuration = configuration self._cache = {} self._copy = configuration.get(FEATURE_MANAGEMENT_KEY) - self._telemetry = kwargs.pop("telemetry", None) + self._on_feature_evaluated = kwargs.pop("on_feature_evaluated", None) filters = [TimeWindowFilter(), TargetingFilter()] + kwargs.pop(PROVIDED_FEATURE_FILTERS, []) for feature_filter in filters: @@ -139,12 +139,12 @@ async def get_variant(self, feature_flag_id, **kwargs): :rtype: str """ result = await self._check_feature(feature_flag_id, **kwargs) - if self._telemetry and result.feature.telemetry.enabled: + if self._on_feature_evaluated and result.feature.telemetry.enabled: result.user = kwargs.get("user", "") - if inspect.iscoroutinefunction(self._telemetry): - await self._telemetry(result) + if inspect.iscoroutinefunction(self._on_feature_evaluated): + await self._on_feature_evaluated(result) else: - self._telemetry(result) + self._on_feature_evaluated(result) return result.variant async def _check_feature_filters(self, feature_flag, evaluation_event, **kwargs): From 40a1275b7484540891aa82aa3a54b64974dbca72 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Mon, 13 May 2024 11:35:02 -0700 Subject: [PATCH 13/35] Adding missing eval reason. Fixed formatting. --- featuremanagement/_featuremanager.py | 11 ++++++++--- featuremanagement/aio/_featuremanager.py | 3 ++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/featuremanagement/_featuremanager.py b/featuremanagement/_featuremanager.py index b733c84..2e02ee3 100644 --- a/featuremanagement/_featuremanager.py +++ b/featuremanagement/_featuremanager.py @@ -62,7 +62,8 @@ class FeatureManager: :param Mapping configuration: Configuration object. :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[EvaluationEvent] on_feature_evaluated: Callback function to be called when a feature flag is + evaluated. """ def __init__(self, configuration, **kwargs): @@ -84,17 +85,21 @@ def __init__(self, configuration, **kwargs): def _check_default_disabled_variant(feature_flag): if not feature_flag.allocation: return EvaluationEvent(enabled=False) - return FeatureManager._check_variant_override( + evaluation_event = FeatureManager._check_variant_override( feature_flag.variants, feature_flag.allocation.default_when_disabled, False ) + evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_DISABLED + return evaluation_event @staticmethod def _check_default_enabled_variant(feature_flag): if not feature_flag.allocation: return EvaluationEvent(enabled=True) - return FeatureManager._check_variant_override( + evaluation_event = FeatureManager._check_variant_override( feature_flag.variants, feature_flag.allocation.default_when_enabled, True ) + evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_ENABLED + return evaluation_event @staticmethod def _check_variant_override(variants, default_variant_name, status): diff --git a/featuremanagement/aio/_featuremanager.py b/featuremanagement/aio/_featuremanager.py index c881da9..964473e 100644 --- a/featuremanagement/aio/_featuremanager.py +++ b/featuremanagement/aio/_featuremanager.py @@ -26,7 +26,8 @@ class FeatureManager: :param Mapping configuration: Configuration object. :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[EvaluationEvent] on_feature_evaluated: Callback function to be called when a feature flag is + evaluated. """ def __init__(self, configuration, **kwargs): From 7a41fa3209e7ed2d85dc04dbcf83a3836d3d0839 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Wed, 3 Jul 2024 13:59:45 -0700 Subject: [PATCH 14/35] Fixing Merge issue --- featuremanagement/_featuremanager.py | 32 +++++++++++----- featuremanagement/aio/_featuremanager.py | 48 ++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/featuremanagement/_featuremanager.py b/featuremanagement/_featuremanager.py index a087640..09cf799 100644 --- a/featuremanagement/_featuremanager.py +++ b/featuremanagement/_featuremanager.py @@ -155,6 +155,12 @@ def _is_targeted(context_id): return (context_marker / (2**32 - 1)) * 100 def _assign_variant(self, feature_flag, targeting_context): + """ + Assign a variant to the user based on the allocation. + :param FeatureFlag feature_flag: Feature flag object. + :param TargetingContext targeting_context: Targeting context. + :return: Variant name. + """ evaluation_event = EvaluationEvent(feature_flag=feature_flag) if not feature_flag.variants or not feature_flag.allocation: return None, evaluation_event @@ -197,6 +203,20 @@ def _variant_name_to_variant(self, feature_flag, variant_name): configuration = self._configuration.get(variant_reference.configuration_reference) return Variant(variant_reference.name, configuration) return None + + + def _build_targeting_context(self, args): + """ + Builds a TargetingContext, either returns a provided context, takes the provided user_id to make a context, or + returns an empty context. + :param args: Arguments to build the TargetingContext. + :return: TargetingContext + """ + if len(args) == 1 and isinstance(args[0], str): + return TargetingContext(user_id=args[0], groups=[]) + if len(args) == 1 and isinstance(args[0], TargetingContext): + return args[0] + return TargetingContext() @overload def is_enabled(self, feature_flag_id, user_id, **kwargs): @@ -217,11 +237,7 @@ def is_enabled(self, feature_flag_id, *args, **kwargs): :return: True if the feature flag is enabled for the given context. :rtype: bool """ - targeting_context = TargetingContext() - if len(args) == 1 and isinstance(args[0], str): - targeting_context = TargetingContext(user_id=args[0], groups=[]) - elif len(args) == 1 and isinstance(args[0], TargetingContext): - targeting_context = args[0] + targeting_context = self._build_targeting_context(args) result = self._check_feature(feature_flag_id, targeting_context, **kwargs) if self._on_feature_evaluated and result.feature.telemetry.enabled: @@ -249,11 +265,7 @@ def get_variant(self, feature_flag_id, *args, **kwargs): :return: Variant instance. :rtype: Variant """ - targeting_context = TargetingContext() - if len(args) == 1 and isinstance(args[0], str): - targeting_context = TargetingContext(user_id=args[0], groups=[]) - elif len(args) == 1 and isinstance(args[0], TargetingContext): - targeting_context = args[0] + targeting_context = self._build_targeting_context(args) result = self._check_feature(feature_flag_id, targeting_context, **kwargs) if self._on_feature_evaluated and result.feature.telemetry.enabled: diff --git a/featuremanagement/aio/_featuremanager.py b/featuremanagement/aio/_featuremanager.py index 63442d0..756dd5f 100644 --- a/featuremanagement/aio/_featuremanager.py +++ b/featuremanagement/aio/_featuremanager.py @@ -26,7 +26,7 @@ class FeatureManager: Feature Manager that determines if a feature flag is enabled for the given context. :param Mapping configuration: Configuration object. - :keyword list[FeatureFilter] feature_filters: Custom filters to be used for evaluating feature flags + :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. """ @@ -105,6 +105,12 @@ def _is_targeted(context_id): return (context_marker / (2**32 - 1)) * 100 def _assign_variant(self, feature_flag, **kwargs): + """ + Assign a variant to the user based on the allocation. + :param FeatureFlag feature_flag: Feature flag object. + :param TargetingContext targeting_context: Targeting context. + :return: Variant name. + """ user = kwargs.get("user", "") groups = kwargs.get("groups", []) evaluation_event = EvaluationEvent(feature_flag=feature_flag) @@ -150,15 +156,28 @@ def _variant_name_to_variant(self, feature_flag, variant_name): return Variant(variant_reference.name, configuration) return None - async def _build_targeting_context(self, args): + def _build_targeting_context(self, args): + """ + Builds a TargetingContext, either returns a provided context, takes the provided user_id to make a context, or + returns an empty context. + :param args: Arguments to build the TargetingContext. + :return: TargetingContext + """ + if len(args) == 1 and isinstance(args[0], str): + return TargetingContext(user_id=args[0], groups=[]) + if len(args) == 1 and isinstance(args[0], TargetingContext): + return args[0] + return TargetingContext() + + @overload + async def is_enabled(self, feature_flag_id, user_id, **kwargs): """ Determine if the feature flag is enabled for the given context. - :param str feature_flag_id: Name of the feature flag. + :param str user_id: User identifier. :return: True if the feature flag is enabled for the given context. :rtype: bool """ - return (await self._check_feature(feature_flag_id, **kwargs)).enabled async def is_enabled(self, feature_flag_id, *args, **kwargs): """ @@ -176,6 +195,27 @@ async def is_enabled(self, feature_flag_id, *args, **kwargs): else: self._on_feature_evaluated(result) return result.variant + + @overload + async def get_variant(self, feature_flag_id, user_id, **kwargs): + """ + Determine the variant for the given context. + :param str feature_flag_id: Name of the feature flag. + :param str user_id: User identifier. + :return: return: Variant instance. + :rtype: Variant + """ + + async def get_variant(self, feature_flag_id, *args, **kwargs): + """ + Determine the variant for the given context. + :param str feature_flag_id: Name of the feature flag. + :return: Name of the variant. + :rtype: str + """ + targeting_context = self._build_targeting_context(args) + result = await self._check_feature(feature_flag_id, targeting_context, **kwargs) + return result.variant async def _check_feature_filters(self, feature_flag, evaluation_event, **kwargs): feature_conditions = feature_flag.conditions From f839860443a91e846778bf0cdd1908a4f28ee984 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Wed, 3 Jul 2024 14:09:03 -0700 Subject: [PATCH 15/35] fixing merge --- featuremanagement/_featuremanager.py | 13 ++-- featuremanagement/aio/_featuremanager.py | 87 ++++++------------------ 2 files changed, 25 insertions(+), 75 deletions(-) diff --git a/featuremanagement/_featuremanager.py b/featuremanagement/_featuremanager.py index 09cf799..4b3e787 100644 --- a/featuremanagement/_featuremanager.py +++ b/featuremanagement/_featuremanager.py @@ -156,11 +156,11 @@ def _is_targeted(context_id): def _assign_variant(self, feature_flag, targeting_context): """ - Assign a variant to the user based on the allocation. - :param FeatureFlag feature_flag: Feature flag object. - :param TargetingContext targeting_context: Targeting context. - :return: Variant name. - """ + Assign a variant to the user based on the allocation. + :param FeatureFlag feature_flag: Feature flag object. + :param TargetingContext targeting_context: Targeting context. + :return: Variant name. + """ evaluation_event = EvaluationEvent(feature_flag=feature_flag) if not feature_flag.variants or not feature_flag.allocation: return None, evaluation_event @@ -203,8 +203,7 @@ def _variant_name_to_variant(self, feature_flag, variant_name): configuration = self._configuration.get(variant_reference.configuration_reference) return Variant(variant_reference.name, configuration) return None - - + def _build_targeting_context(self, args): """ Builds a TargetingContext, either returns a provided context, takes the provided user_id to make a context, or diff --git a/featuremanagement/aio/_featuremanager.py b/featuremanagement/aio/_featuremanager.py index 756dd5f..48da1d4 100644 --- a/featuremanagement/aio/_featuremanager.py +++ b/featuremanagement/aio/_featuremanager.py @@ -157,22 +157,24 @@ def _variant_name_to_variant(self, feature_flag, variant_name): return None def _build_targeting_context(self, args): - """ - Builds a TargetingContext, either returns a provided context, takes the provided user_id to make a context, or - returns an empty context. - :param args: Arguments to build the TargetingContext. - :return: TargetingContext - """ - if len(args) == 1 and isinstance(args[0], str): - return TargetingContext(user_id=args[0], groups=[]) - if len(args) == 1 and isinstance(args[0], TargetingContext): - return args[0] - return TargetingContext() + """ + Builds a TargetingContext, either returns a provided context, takes the provided user_id to make a context, or + returns an empty context. + + :param args: Arguments to build the TargetingContext. + :return: TargetingContext + """ + if len(args) == 1 and isinstance(args[0], str): + return TargetingContext(user_id=args[0], groups=[]) + if len(args) == 1 and isinstance(args[0], TargetingContext): + return args[0] + return TargetingContext() @overload async def is_enabled(self, feature_flag_id, user_id, **kwargs): """ Determine if the feature flag is enabled for the given context. + :param str feature_flag_id: Name of the feature flag. :param str user_id: User identifier. :return: True if the feature flag is enabled for the given context. @@ -181,11 +183,10 @@ async def is_enabled(self, feature_flag_id, user_id, **kwargs): async def is_enabled(self, feature_flag_id, *args, **kwargs): """ - Determine the variant for the given context. + Determine if the feature flag is enabled for the given context. - :param str feature_flag_id: Name of the feature flag. - :return: Name of the variant. - :rtype: str + :return: True if the feature flag is enabled for the given context. + :rtype: bool """ result = await self._check_feature(feature_flag_id, **kwargs) if self._on_feature_evaluated and result.feature.telemetry.enabled: @@ -195,11 +196,12 @@ async def is_enabled(self, feature_flag_id, *args, **kwargs): else: self._on_feature_evaluated(result) return result.variant - + @overload async def get_variant(self, feature_flag_id, user_id, **kwargs): """ Determine the variant for the given context. + :param str feature_flag_id: Name of the feature flag. :param str user_id: User identifier. :return: return: Variant instance. @@ -209,6 +211,7 @@ async def get_variant(self, feature_flag_id, user_id, **kwargs): async def get_variant(self, feature_flag_id, *args, **kwargs): """ Determine the variant for the given context. + :param str feature_flag_id: Name of the feature flag. :return: Name of the variant. :rtype: str @@ -217,58 +220,6 @@ async def get_variant(self, feature_flag_id, *args, **kwargs): result = await self._check_feature(feature_flag_id, targeting_context, **kwargs) return result.variant - async def _check_feature_filters(self, feature_flag, evaluation_event, **kwargs): - feature_conditions = feature_flag.conditions - feature_filters = feature_conditions.client_filters - - if len(feature_filters) == 0: - # Feature flags without any filters return evaluate - evaluation_event.enabled = True - else: - # The assumed value is no filters is based on the requirement type. - # Requirement type Any assumes false until proven true, All assumes true until proven false - evaluation_event.enabled = feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL - - for feature_filter in feature_filters: - filter_name = feature_filter[FEATURE_FILTER_NAME] - if filter_name not in self._filters: - raise ValueError(f"Feature flag {feature_flag.name} has unknown filter {filter_name}") - if feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL: - if not await self._filters[filter_name].evaluate(feature_filter, **kwargs): - evaluation_event.enabled = False - break - else: - if await self._filters[filter_name].evaluate(feature_filter, **kwargs): - evaluation_event.enabled = True - break - return evaluation_event - - def _assign_allocation(self, feature_flag, evaluation_event, **kwargs): - if feature_flag.allocation and feature_flag.variants: - default_enabled = evaluation_event.enabled - variant_name, evaluation_event = self._assign_variant(feature_flag, **kwargs) - evaluation_event.enabled = default_enabled - if variant_name: - evaluation_event = FeatureManager._check_variant_override( - feature_flag.variants, variant_name, evaluation_event.enabled - ) - evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) - evaluation_event.feature = feature_flag - return evaluation_event - - variant_name = None - if evaluation_event.enabled: - evaluation_event = FeatureManager._check_default_enabled_variant(feature_flag) - if feature_flag.allocation: - variant_name = feature_flag.allocation.default_when_enabled - else: - evaluation_event = FeatureManager._check_default_disabled_variant(feature_flag) - if feature_flag.allocation: - variant_name = feature_flag.allocation.default_when_disabled - evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) - evaluation_event.feature = feature_flag - return evaluation_event - async def _check_feature_filters(self, feature_flag, targeting_context, **kwargs): feature_conditions = feature_flag.conditions feature_filters = feature_conditions.client_filters From 48a4c228030a7606ced3af5ca200e320d3817cdb Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Mon, 8 Jul 2024 15:32:56 -0700 Subject: [PATCH 16/35] fixing merge --- featuremanagement/_featuremanager.py | 4 ++- featuremanagement/aio/_featuremanager.py | 45 +++++++++++++----------- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/featuremanagement/_featuremanager.py b/featuremanagement/_featuremanager.py index 4b3e787..cdce124 100644 --- a/featuremanagement/_featuremanager.py +++ b/featuremanagement/_featuremanager.py @@ -84,7 +84,7 @@ def __init__(self, configuration, **kwargs): self._configuration = configuration self._cache = {} self._copy = configuration.get(FEATURE_MANAGEMENT_KEY) - self._on_feature_evaluated = kwargs.get("on_feature_evaluated", None) + self._on_feature_evaluated = kwargs.pop("on_feature_evaluated", None) filters = [TimeWindowFilter(), TargetingFilter()] + kwargs.pop(PROVIDED_FEATURE_FILTERS, []) for feature_filter in filters: @@ -157,6 +157,7 @@ def _is_targeted(context_id): def _assign_variant(self, feature_flag, targeting_context): """ Assign a variant to the user based on the allocation. + :param FeatureFlag feature_flag: Feature flag object. :param TargetingContext targeting_context: Targeting context. :return: Variant name. @@ -208,6 +209,7 @@ def _build_targeting_context(self, args): """ Builds a TargetingContext, either returns a provided context, takes the provided user_id to make a context, or returns an empty context. + :param args: Arguments to build the TargetingContext. :return: TargetingContext """ diff --git a/featuremanagement/aio/_featuremanager.py b/featuremanagement/aio/_featuremanager.py index 48da1d4..bce157a 100644 --- a/featuremanagement/aio/_featuremanager.py +++ b/featuremanagement/aio/_featuremanager.py @@ -104,26 +104,25 @@ def _is_targeted(context_id): return (context_marker / (2**32 - 1)) * 100 - def _assign_variant(self, feature_flag, **kwargs): + def _assign_variant(self, feature_flag, targeting_context): """ Assign a variant to the user based on the allocation. + :param FeatureFlag feature_flag: Feature flag object. :param TargetingContext targeting_context: Targeting context. :return: Variant name. """ - user = kwargs.get("user", "") - groups = kwargs.get("groups", []) evaluation_event = EvaluationEvent(feature_flag=feature_flag) if not feature_flag.variants or not feature_flag.allocation: return None - if feature_flag.allocation.user and user: + if feature_flag.allocation.user and targeting_context.user_id: for user_allocation in feature_flag.allocation.user: - if user in user_allocation.users: + if targeting_context.user_id in user_allocation.users: evaluation_event.reason = VariantAssignmentReason.USER return user_allocation.variant, evaluation_event - if feature_flag.allocation.group and groups: + if feature_flag.allocation.group and targeting_context.groups: for group_allocation in feature_flag.allocation.group: - for group in groups: + for group in targeting_context.groups: if group in group_allocation.groups: evaluation_event.reason = VariantAssignmentReason.GROUP return group_allocation.variant, evaluation_event @@ -160,7 +159,7 @@ def _build_targeting_context(self, args): """ Builds a TargetingContext, either returns a provided context, takes the provided user_id to make a context, or returns an empty context. - + :param args: Arguments to build the TargetingContext. :return: TargetingContext """ @@ -174,7 +173,7 @@ def _build_targeting_context(self, args): async def is_enabled(self, feature_flag_id, user_id, **kwargs): """ Determine if the feature flag is enabled for the given context. - + :param str feature_flag_id: Name of the feature flag. :param str user_id: User identifier. :return: True if the feature flag is enabled for the given context. @@ -185,17 +184,19 @@ async def is_enabled(self, feature_flag_id, *args, **kwargs): """ Determine if the feature flag is enabled for the given context. + :param str feature_flag_id: Name of the feature flag. :return: True if the feature flag is enabled for the given context. :rtype: bool """ - result = await self._check_feature(feature_flag_id, **kwargs) + targeting_context = self._build_targeting_context(args) + result = await self._check_feature(feature_flag_id, targeting_context, **kwargs) if self._on_feature_evaluated and result.feature.telemetry.enabled: result.user = kwargs.get("user", "") if inspect.iscoroutinefunction(self._on_feature_evaluated): await self._on_feature_evaluated(result) else: self._on_feature_evaluated(result) - return result.variant + return result.enabled @overload async def get_variant(self, feature_flag_id, user_id, **kwargs): @@ -211,7 +212,7 @@ async def get_variant(self, feature_flag_id, user_id, **kwargs): async def get_variant(self, feature_flag_id, *args, **kwargs): """ Determine the variant for the given context. - + :param str feature_flag_id: Name of the feature flag. :return: Name of the variant. :rtype: str @@ -220,10 +221,9 @@ async def get_variant(self, feature_flag_id, *args, **kwargs): result = await self._check_feature(feature_flag_id, targeting_context, **kwargs) return result.variant - async def _check_feature_filters(self, feature_flag, targeting_context, **kwargs): + async def _check_feature_filters(self, feature_flag, evaluation_event, targeting_context, **kwargs): feature_conditions = feature_flag.conditions feature_filters = feature_conditions.client_filters - evaluation_event = EvaluationEvent(enabled=False) if len(feature_filters) == 0: # Feature flags without any filters return evaluate @@ -243,15 +243,16 @@ async def _check_feature_filters(self, feature_flag, targeting_context, **kwargs if not await self._filters[filter_name].evaluate(feature_filter, **kwargs): evaluation_event.enabled = False break - else: - if await self._filters[filter_name].evaluate(feature_filter, **kwargs): - evaluation_event.enabled = True - break + elif await self._filters[filter_name].evaluate(feature_filter, **kwargs): + evaluation_event.enabled = True + break return evaluation_event def _assign_allocation(self, feature_flag, evaluation_event, targeting_context, **kwargs): if feature_flag.allocation and feature_flag.variants: - variant_name = self._assign_variant(feature_flag, targeting_context, **kwargs) + default_enabled = evaluation_event.enabled + variant_name, evaluation_event = self._assign_variant(feature_flag, targeting_context, **kwargs) + evaluation_event.enabled = default_enabled if variant_name: evaluation_event = FeatureManager._check_variant_override( feature_flag.variants, variant_name, evaluation_event.enabled @@ -306,8 +307,10 @@ async def _check_feature(self, feature_flag_id, targeting_context, **kwargs): evaluation_event.feature = feature_flag return evaluation_event - evaluation_event = await self._check_feature_filters(feature_flag, evaluation_event, **kwargs) - return self._assign_allocation(feature_flag, evaluation_event, **kwargs) + evaluation_event = await self._check_feature_filters( + feature_flag, evaluation_event, targeting_context, **kwargs + ) + return self._assign_allocation(feature_flag, evaluation_event, targeting_context, **kwargs) def list_feature_flag_names(self): """ From 4cd65c3d87e3cc3677ce8a6228c98389b99711e3 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Tue, 9 Jul 2024 10:18:55 -0700 Subject: [PATCH 17/35] review comments --- featuremanagement/_models/_variant_assignment_reason.py | 2 +- featuremanagement/aio/_featuremanager.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/featuremanagement/_models/_variant_assignment_reason.py b/featuremanagement/_models/_variant_assignment_reason.py index ffb55b1..5e8c2c7 100644 --- a/featuremanagement/_models/_variant_assignment_reason.py +++ b/featuremanagement/_models/_variant_assignment_reason.py @@ -11,7 +11,7 @@ class VariantAssignmentReason(Enum): Represents an assignment reason. """ - NONE = "NONE" + NONE = "None" DEFAULT_WHEN_DISABLED = "DefaultWhenDisabled" DEFAULT_WHEN_ENABLED = "DefaultWhenEnabled" USER = "User" diff --git a/featuremanagement/aio/_featuremanager.py b/featuremanagement/aio/_featuremanager.py index bce157a..4144ab2 100644 --- a/featuremanagement/aio/_featuremanager.py +++ b/featuremanagement/aio/_featuremanager.py @@ -189,9 +189,10 @@ async def is_enabled(self, feature_flag_id, *args, **kwargs): :rtype: bool """ targeting_context = self._build_targeting_context(args) + result = await self._check_feature(feature_flag_id, targeting_context, **kwargs) if self._on_feature_evaluated and result.feature.telemetry.enabled: - result.user = kwargs.get("user", "") + result.user = targeting_context.user_id if inspect.iscoroutinefunction(self._on_feature_evaluated): await self._on_feature_evaluated(result) else: From 0fb20002d8da4051182dfd944a15e1e09d69b516 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Wed, 10 Jul 2024 11:51:44 -0700 Subject: [PATCH 18/35] Updating evaluation event usage --- featuremanagement/_featuremanager.py | 76 ++++++++++++------------ featuremanagement/_version.py | 2 +- featuremanagement/aio/_featuremanager.py | 74 ++++++++++++----------- 3 files changed, 79 insertions(+), 73 deletions(-) diff --git a/featuremanagement/_featuremanager.py b/featuremanagement/_featuremanager.py index cdce124..d9ad73e 100644 --- a/featuremanagement/_featuremanager.py +++ b/featuremanagement/_featuremanager.py @@ -93,58 +93,60 @@ def __init__(self, configuration, **kwargs): self._filters[feature_filter.name] = feature_filter @staticmethod - def _check_default_disabled_variant(feature_flag): + def _check_default_disabled_variant(feature_flag, evaluation_event): """ A method called when the feature flag is disabled, to determine what the default variant should be. If there is no allocation, then None is set as the value of the variant in the EvaluationEvent. :param FeatureFlag feature_flag: Feature flag object. - :return: EvaluationEvent + :param EvaluationEvent evaluation_event: Evaluation event object. """ if not feature_flag.allocation: - return EvaluationEvent(enabled=False) - evaluation_event = FeatureManager._check_variant_override( - feature_flag.variants, feature_flag.allocation.default_when_disabled, False + evaluation_event.enabled = False + return + FeatureManager._check_variant_override( + feature_flag.variants, feature_flag.allocation.default_when_disabled, False, evaluation_event ) evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_DISABLED - return evaluation_event @staticmethod - def _check_default_enabled_variant(feature_flag): + def _check_default_enabled_variant(feature_flag, evaluation_event): """ A method called when the feature flag is enabled, to determine what the default variant should be. If there is no allocation, then None is set as the value of the variant in the EvaluationEvent. :param FeatureFlag feature_flag: Feature flag object. - :return: EvaluationEvent + :param EvaluationEvent evaluation_event: Evaluation event object. """ if not feature_flag.allocation: - return EvaluationEvent(enabled=True) - evaluation_event = FeatureManager._check_variant_override( - feature_flag.variants, feature_flag.allocation.default_when_enabled, True + evaluation_event.enabled = True + return + FeatureManager._check_variant_override( + feature_flag.variants, feature_flag.allocation.default_when_enabled, True, evaluation_event ) evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_ENABLED - return evaluation_event @staticmethod - def _check_variant_override(variants, default_variant_name, status): + def _check_variant_override(variants, default_variant_name, status, evaluation_event): """ A method to check if a variant is overridden to be enabled or disabled by the variant. :param list[Variant] variants: List of variants. :param str default_variant_name: Name of the default variant. :param bool status: Status of the feature flag. - :return: EvaluationEvent + :param EvaluationEvent evaluation_event: Evaluation event object. """ if not variants or not default_variant_name: - return EvaluationEvent(enabled=status) + evaluation_event.enabled = status for variant in variants: if variant.name == default_variant_name: if variant.status_override == "Enabled": - return EvaluationEvent(enabled=True) + evaluation_event.enabled = True + return if variant.status_override == "Disabled": - return EvaluationEvent(enabled=False) - return EvaluationEvent(enabled=status) + evaluation_event.enabled = False + return + evaluation_event.enabled = status @staticmethod def _is_targeted(context_id): @@ -154,28 +156,29 @@ def _is_targeted(context_id): return (context_marker / (2**32 - 1)) * 100 - def _assign_variant(self, feature_flag, targeting_context): + def _assign_variant(self, feature_flag, targeting_context, evaluation_event): """ Assign a variant to the user based on the allocation. :param FeatureFlag feature_flag: Feature flag object. :param TargetingContext targeting_context: Targeting context. + :param EvaluationEvent evaluation_event: Evaluation event object. :return: Variant name. """ - evaluation_event = EvaluationEvent(feature_flag=feature_flag) + evaluation_event.feature = feature_flag if not feature_flag.variants or not feature_flag.allocation: - return None, evaluation_event + return None if feature_flag.allocation.user and targeting_context.user_id: for user_allocation in feature_flag.allocation.user: if targeting_context.user_id in user_allocation.users: evaluation_event.reason = VariantAssignmentReason.USER - return user_allocation.variant, evaluation_event + return user_allocation.variant if feature_flag.allocation.group and len(targeting_context.groups) > 0: for group_allocation in feature_flag.allocation.group: for group in targeting_context.groups: if group in group_allocation.groups: evaluation_event.reason = VariantAssignmentReason.GROUP - return group_allocation.variant, evaluation_event + return group_allocation.variant if feature_flag.allocation.percentile: context_id = targeting_context.user_id + "\n" + feature_flag.allocation.seed box = self._is_targeted(context_id) @@ -184,8 +187,8 @@ def _assign_variant(self, feature_flag, targeting_context): return percentile_allocation.variant if percentile_allocation.percentile_from <= box < percentile_allocation.percentile_to: evaluation_event.reason = VariantAssignmentReason.PERCENTILE - return percentile_allocation.variant, evaluation_event - return None, evaluation_event + return percentile_allocation.variant + return None def _variant_name_to_variant(self, feature_flag, variant_name): """ @@ -299,33 +302,31 @@ def _check_feature_filters(self, feature_flag, evaluation_event, targeting_conte elif self._filters[filter_name].evaluate(feature_filter, **kwargs): evaluation_event.enabled = True break - return evaluation_event def _assign_allocation(self, feature_flag, evaluation_event, targeting_context): if feature_flag.allocation and feature_flag.variants: default_enabled = evaluation_event.enabled - variant_name, evaluation_event = self._assign_variant(feature_flag, targeting_context) + variant_name = self._assign_variant(feature_flag, targeting_context, evaluation_event) evaluation_event.enabled = default_enabled if variant_name: - evaluation_event.enabled = FeatureManager._check_variant_override( - feature_flag.variants, variant_name, evaluation_event.enabled - ).enabled + FeatureManager._check_variant_override( + feature_flag.variants, variant_name, evaluation_event.enabled, evaluation_event + ) evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) evaluation_event.feature = feature_flag - return evaluation_event + return variant_name = None if evaluation_event.enabled: - evaluation_event = FeatureManager._check_default_enabled_variant(feature_flag) + FeatureManager._check_default_enabled_variant(feature_flag, evaluation_event) if feature_flag.allocation: variant_name = feature_flag.allocation.default_when_enabled else: - evaluation_event = FeatureManager._check_default_disabled_variant(feature_flag) + FeatureManager._check_default_disabled_variant(feature_flag, evaluation_event) if feature_flag.allocation: variant_name = feature_flag.allocation.default_when_disabled evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) evaluation_event.feature = feature_flag - return evaluation_event def _check_feature(self, feature_flag_id, targeting_context, **kwargs): """ @@ -353,16 +354,17 @@ def _check_feature(self, feature_flag_id, targeting_context, **kwargs): if not feature_flag.enabled: # Feature flags that are disabled are always disabled - evaluation_event = FeatureManager._check_default_disabled_variant(feature_flag) + FeatureManager._check_default_disabled_variant(feature_flag, evaluation_event) if feature_flag.allocation: variant_name = feature_flag.allocation.default_when_disabled evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) evaluation_event.feature = feature_flag return evaluation_event - evaluation_event = self._check_feature_filters(feature_flag, evaluation_event, targeting_context, **kwargs) + self._check_feature_filters(feature_flag, evaluation_event, targeting_context, **kwargs) - return self._assign_allocation(feature_flag, evaluation_event, targeting_context) + self._assign_allocation(feature_flag, evaluation_event, targeting_context) + return evaluation_event def list_feature_flag_names(self): """ diff --git a/featuremanagement/_version.py b/featuremanagement/_version.py index f0d8f67..32e04dd 100644 --- a/featuremanagement/_version.py +++ b/featuremanagement/_version.py @@ -4,4 +4,4 @@ # license information. # ------------------------------------------------------------------------- -VERSION = "1.0.0" +VERSION = "2.0.0b1" diff --git a/featuremanagement/aio/_featuremanager.py b/featuremanagement/aio/_featuremanager.py index 4144ab2..8e86cf2 100644 --- a/featuremanagement/aio/_featuremanager.py +++ b/featuremanagement/aio/_featuremanager.py @@ -47,54 +47,59 @@ def __init__(self, configuration, **kwargs): self._filters[feature_filter.name] = feature_filter @staticmethod - def _check_default_disabled_variant(feature_flag): + def _check_default_disabled_variant(feature_flag, evaluation_event): """ A method called when the feature flag is disabled, to determine what the default variant should be. If there is no allocation, then None is set as the value of the variant in the EvaluationEvent. :param FeatureFlag feature_flag: Feature flag object. - :return: EvaluationEvent + :param EvaluationEvent evaluation_event: Evaluation event object. """ if not feature_flag.allocation: - return EvaluationEvent(enabled=False) - return FeatureManager._check_variant_override( - feature_flag.variants, feature_flag.allocation.default_when_disabled, False + evaluation_event.enabled = False + return + FeatureManager._check_variant_override( + feature_flag.variants, feature_flag.allocation.default_when_disabled, False, evaluation_event ) @staticmethod - def _check_default_enabled_variant(feature_flag): + def _check_default_enabled_variant(feature_flag, evaluation_event): """ A method called when the feature flag is enabled, to determine what the default variant should be. If there is no allocation, then None is set as the value of the variant in the EvaluationEvent. :param FeatureFlag feature_flag: Feature flag object. - :return: EvaluationEvent + :param EvaluationEvent evaluation_event: Evaluation event object. """ if not feature_flag.allocation: - return EvaluationEvent(enabled=True) - return FeatureManager._check_variant_override( - feature_flag.variants, feature_flag.allocation.default_when_enabled, True + evaluation_event.enabled = True + return + FeatureManager._check_variant_override( + feature_flag.variants, feature_flag.allocation.default_when_enabled, True, evaluation_event ) @staticmethod - def _check_variant_override(variants, default_variant_name, status): + def _check_variant_override(variants, default_variant_name, status, evaluation_event): """ A method to check if a variant is overridden to be enabled or disabled by the variant. :param list[Variant] variants: List of variants. :param str default_variant_name: Name of the default variant. :param bool status: Status of the feature flag. - :return: EvaluationEvent + :param EvaluationEvent evaluation_event: Evaluation event object. """ if not variants or not default_variant_name: - return EvaluationEvent(enabled=status) + evaluation_event.enabled = status + return for variant in variants: if variant.name == default_variant_name: if variant.status_override == "Enabled": - return EvaluationEvent(enabled=True) + evaluation_event.enabled = True + return if variant.status_override == "Disabled": - return EvaluationEvent(enabled=False) - return EvaluationEvent(enabled=status) + evaluation_event.enabled = False + return + evaluation_event.enabled = status @staticmethod def _is_targeted(context_id): @@ -104,28 +109,29 @@ def _is_targeted(context_id): return (context_marker / (2**32 - 1)) * 100 - def _assign_variant(self, feature_flag, targeting_context): + def _assign_variant(self, feature_flag, targeting_context, evaluation_event): """ Assign a variant to the user based on the allocation. :param FeatureFlag feature_flag: Feature flag object. :param TargetingContext targeting_context: Targeting context. + :param EvaluationEvent evaluation_event: Evaluation event object. :return: Variant name. """ - evaluation_event = EvaluationEvent(feature_flag=feature_flag) + evaluation_event.feature = feature_flag if not feature_flag.variants or not feature_flag.allocation: return None if feature_flag.allocation.user and targeting_context.user_id: for user_allocation in feature_flag.allocation.user: if targeting_context.user_id in user_allocation.users: evaluation_event.reason = VariantAssignmentReason.USER - return user_allocation.variant, evaluation_event + return user_allocation.variant if feature_flag.allocation.group and targeting_context.groups: for group_allocation in feature_flag.allocation.group: for group in targeting_context.groups: if group in group_allocation.groups: evaluation_event.reason = VariantAssignmentReason.GROUP - return group_allocation.variant, evaluation_event + return group_allocation.variant if feature_flag.allocation.percentile: context_id = targeting_context.user_id + "\n" + feature_flag.allocation.seed box = self._is_targeted(context_id) @@ -134,8 +140,8 @@ def _assign_variant(self, feature_flag, targeting_context): return percentile_allocation.variant if percentile_allocation.percentile_from <= box < percentile_allocation.percentile_to: evaluation_event.reason = VariantAssignmentReason.PERCENTILE - return percentile_allocation.variant, evaluation_event - return None, evaluation_event + return percentile_allocation.variant + return None def _variant_name_to_variant(self, feature_flag, variant_name): """ @@ -247,33 +253,31 @@ async def _check_feature_filters(self, feature_flag, evaluation_event, targeting elif await self._filters[filter_name].evaluate(feature_filter, **kwargs): evaluation_event.enabled = True break - return evaluation_event def _assign_allocation(self, feature_flag, evaluation_event, targeting_context, **kwargs): if feature_flag.allocation and feature_flag.variants: default_enabled = evaluation_event.enabled - variant_name, evaluation_event = self._assign_variant(feature_flag, targeting_context, **kwargs) + variant_name = self._assign_variant(feature_flag, targeting_context, evaluation_event, **kwargs) evaluation_event.enabled = default_enabled if variant_name: - evaluation_event = FeatureManager._check_variant_override( - feature_flag.variants, variant_name, evaluation_event.enabled + FeatureManager._check_variant_override( + feature_flag.variants, variant_name, evaluation_event.enabled, evaluation_event ) evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) evaluation_event.feature = feature_flag - return evaluation_event + return variant_name = None if evaluation_event.enabled: - evaluation_event = FeatureManager._check_default_enabled_variant(feature_flag) + FeatureManager._check_default_enabled_variant(feature_flag, evaluation_event) if feature_flag.allocation: variant_name = feature_flag.allocation.default_when_enabled else: - evaluation_event = FeatureManager._check_default_disabled_variant(feature_flag) + FeatureManager._check_default_disabled_variant(feature_flag, evaluation_event) if feature_flag.allocation: variant_name = feature_flag.allocation.default_when_disabled evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) evaluation_event.feature = feature_flag - return evaluation_event async def _check_feature(self, feature_flag_id, targeting_context, **kwargs): """ @@ -301,17 +305,17 @@ async def _check_feature(self, feature_flag_id, targeting_context, **kwargs): if not feature_flag.enabled: # Feature flags that are disabled are always disabled - evaluation_event = FeatureManager._check_default_disabled_variant(feature_flag) + FeatureManager._check_default_disabled_variant(feature_flag, evaluation_event) if feature_flag.allocation: variant_name = feature_flag.allocation.default_when_disabled evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) evaluation_event.feature = feature_flag return evaluation_event - evaluation_event = await self._check_feature_filters( - feature_flag, evaluation_event, targeting_context, **kwargs - ) - return self._assign_allocation(feature_flag, evaluation_event, targeting_context, **kwargs) + await self._check_feature_filters(feature_flag, evaluation_event, targeting_context, **kwargs) + + self._assign_allocation(feature_flag, evaluation_event, targeting_context, **kwargs) + return evaluation_event def list_feature_flag_names(self): """ From 851e6bd0366b6a79a59d05248aae7fb1b394dfa3 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Wed, 10 Jul 2024 12:22:40 -0700 Subject: [PATCH 19/35] Updated feature flag usage --- featuremanagement/_featuremanager.py | 65 ++++++++++--------- .../_models/_evaluation_event.py | 4 +- featuremanagement/aio/_featuremanager.py | 48 +++++++------- tests/test_send_telemetry_appinsights.py | 8 +-- 4 files changed, 67 insertions(+), 58 deletions(-) diff --git a/featuremanagement/_featuremanager.py b/featuremanagement/_featuremanager.py index d9ad73e..5d8cd6c 100644 --- a/featuremanagement/_featuremanager.py +++ b/featuremanagement/_featuremanager.py @@ -93,38 +93,42 @@ def __init__(self, configuration, **kwargs): self._filters[feature_filter.name] = feature_filter @staticmethod - def _check_default_disabled_variant(feature_flag, evaluation_event): + def _check_default_disabled_variant(evaluation_event): """ A method called when the feature flag is disabled, to determine what the default variant should be. If there is no allocation, then None is set as the value of the variant in the EvaluationEvent. - :param FeatureFlag feature_flag: Feature flag object. :param EvaluationEvent evaluation_event: Evaluation event object. """ - if not feature_flag.allocation: + evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_DISABLED + if not evaluation_event.feature.allocation: evaluation_event.enabled = False return FeatureManager._check_variant_override( - feature_flag.variants, feature_flag.allocation.default_when_disabled, False, evaluation_event + evaluation_event.feature.variants, + evaluation_event.feature.allocation.default_when_disabled, + False, + evaluation_event, ) - evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_DISABLED @staticmethod - def _check_default_enabled_variant(feature_flag, evaluation_event): + def _check_default_enabled_variant(evaluation_event): """ A method called when the feature flag is enabled, to determine what the default variant should be. If there is no allocation, then None is set as the value of the variant in the EvaluationEvent. - :param FeatureFlag feature_flag: Feature flag object. :param EvaluationEvent evaluation_event: Evaluation event object. """ - if not feature_flag.allocation: + evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_ENABLED + if not evaluation_event.feature.allocation: evaluation_event.enabled = True return FeatureManager._check_variant_override( - feature_flag.variants, feature_flag.allocation.default_when_enabled, True, evaluation_event + evaluation_event.feature.variants, + evaluation_event.feature.allocation.default_when_enabled, + True, + evaluation_event, ) - evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_ENABLED @staticmethod def _check_variant_override(variants, default_variant_name, status, evaluation_event): @@ -156,33 +160,32 @@ def _is_targeted(context_id): return (context_marker / (2**32 - 1)) * 100 - def _assign_variant(self, feature_flag, targeting_context, evaluation_event): + def _assign_variant(self, targeting_context, evaluation_event): """ Assign a variant to the user based on the allocation. - :param FeatureFlag feature_flag: Feature flag object. :param TargetingContext targeting_context: Targeting context. :param EvaluationEvent evaluation_event: Evaluation event object. :return: Variant name. """ - evaluation_event.feature = feature_flag - if not feature_flag.variants or not feature_flag.allocation: + feature = evaluation_event.feature + if not feature.variants or not feature.allocation: return None - if feature_flag.allocation.user and targeting_context.user_id: - for user_allocation in feature_flag.allocation.user: + if feature.allocation.user and targeting_context.user_id: + for user_allocation in feature.allocation.user: if targeting_context.user_id in user_allocation.users: evaluation_event.reason = VariantAssignmentReason.USER return user_allocation.variant - if feature_flag.allocation.group and len(targeting_context.groups) > 0: - for group_allocation in feature_flag.allocation.group: + if feature.allocation.group and len(targeting_context.groups) > 0: + for group_allocation in feature.allocation.group: for group in targeting_context.groups: if group in group_allocation.groups: evaluation_event.reason = VariantAssignmentReason.GROUP return group_allocation.variant - if feature_flag.allocation.percentile: - context_id = targeting_context.user_id + "\n" + feature_flag.allocation.seed + if feature.allocation.percentile: + context_id = targeting_context.user_id + "\n" + feature.allocation.seed box = self._is_targeted(context_id) - for percentile_allocation in feature_flag.allocation.percentile: + for percentile_allocation in feature.allocation.percentile: if box == 100 and percentile_allocation.percentile_to == 100: return percentile_allocation.variant if percentile_allocation.percentile_from <= box < percentile_allocation.percentile_to: @@ -277,7 +280,8 @@ def get_variant(self, feature_flag_id, *args, **kwargs): self._on_feature_evaluated(result) return result.variant - def _check_feature_filters(self, feature_flag, evaluation_event, targeting_context, **kwargs): + def _check_feature_filters(self, evaluation_event, targeting_context, **kwargs): + feature_flag = evaluation_event.feature feature_conditions = feature_flag.conditions feature_filters = feature_conditions.client_filters @@ -303,10 +307,11 @@ def _check_feature_filters(self, feature_flag, evaluation_event, targeting_conte evaluation_event.enabled = True break - def _assign_allocation(self, feature_flag, evaluation_event, targeting_context): + def _assign_allocation(self, evaluation_event, targeting_context): + feature_flag = evaluation_event.feature if feature_flag.allocation and feature_flag.variants: default_enabled = evaluation_event.enabled - variant_name = self._assign_variant(feature_flag, targeting_context, evaluation_event) + variant_name = self._assign_variant(targeting_context, evaluation_event) evaluation_event.enabled = default_enabled if variant_name: FeatureManager._check_variant_override( @@ -318,11 +323,11 @@ def _assign_allocation(self, feature_flag, evaluation_event, targeting_context): variant_name = None if evaluation_event.enabled: - FeatureManager._check_default_enabled_variant(feature_flag, evaluation_event) + FeatureManager._check_default_enabled_variant(evaluation_event) if feature_flag.allocation: variant_name = feature_flag.allocation.default_when_enabled else: - FeatureManager._check_default_disabled_variant(feature_flag, evaluation_event) + FeatureManager._check_default_disabled_variant(evaluation_event) if feature_flag.allocation: variant_name = feature_flag.allocation.default_when_disabled evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) @@ -336,7 +341,6 @@ def _check_feature(self, feature_flag_id, targeting_context, **kwargs): :return: True if the feature flag is enabled for the given context. :rtype: bool """ - evaluation_event = EvaluationEvent(enabled=False) if self._copy is not self._configuration.get(FEATURE_MANAGEMENT_KEY): self._cache = {} self._copy = self._configuration.get(FEATURE_MANAGEMENT_KEY) @@ -347,6 +351,7 @@ def _check_feature(self, feature_flag_id, targeting_context, **kwargs): else: feature_flag = self._cache.get(feature_flag_id) + evaluation_event = EvaluationEvent(feature_flag) if not feature_flag: logging.warning("Feature flag %s not found", feature_flag_id) # Unknown feature flags are disabled by default @@ -354,16 +359,16 @@ def _check_feature(self, feature_flag_id, targeting_context, **kwargs): if not feature_flag.enabled: # Feature flags that are disabled are always disabled - FeatureManager._check_default_disabled_variant(feature_flag, evaluation_event) + FeatureManager._check_default_disabled_variant(evaluation_event) if feature_flag.allocation: variant_name = feature_flag.allocation.default_when_disabled evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) evaluation_event.feature = feature_flag return evaluation_event - self._check_feature_filters(feature_flag, evaluation_event, targeting_context, **kwargs) + self._check_feature_filters(evaluation_event, targeting_context, **kwargs) - self._assign_allocation(feature_flag, evaluation_event, targeting_context) + self._assign_allocation(evaluation_event, targeting_context) return evaluation_event def list_feature_flag_names(self): diff --git a/featuremanagement/_models/_evaluation_event.py b/featuremanagement/_models/_evaluation_event.py index e8a497a..06a4287 100644 --- a/featuremanagement/_models/_evaluation_event.py +++ b/featuremanagement/_models/_evaluation_event.py @@ -12,12 +12,12 @@ class EvaluationEvent: Represents a feature flag evaluation event. """ - def __init__(self, *, enabled=False, feature_flag=None): + def __init__(self, feature_flag): """ Initialize the EvaluationEvent. """ self.feature = feature_flag self.user = "" - self.enabled = enabled + self.enabled = False self.variant = None self.reason = None diff --git a/featuremanagement/aio/_featuremanager.py b/featuremanagement/aio/_featuremanager.py index 8e86cf2..a0b2be9 100644 --- a/featuremanagement/aio/_featuremanager.py +++ b/featuremanagement/aio/_featuremanager.py @@ -47,35 +47,39 @@ def __init__(self, configuration, **kwargs): self._filters[feature_filter.name] = feature_filter @staticmethod - def _check_default_disabled_variant(feature_flag, evaluation_event): + def _check_default_disabled_variant(evaluation_event): """ A method called when the feature flag is disabled, to determine what the default variant should be. If there is no allocation, then None is set as the value of the variant in the EvaluationEvent. - :param FeatureFlag feature_flag: Feature flag object. :param EvaluationEvent evaluation_event: Evaluation event object. """ - if not feature_flag.allocation: + if not evaluation_event.feature.allocation: evaluation_event.enabled = False return FeatureManager._check_variant_override( - feature_flag.variants, feature_flag.allocation.default_when_disabled, False, evaluation_event + evaluation_event.feature.variants, + evaluation_event.feature.allocation.default_when_disabled, + False, + evaluation_event, ) @staticmethod - def _check_default_enabled_variant(feature_flag, evaluation_event): + def _check_default_enabled_variant(evaluation_event): """ A method called when the feature flag is enabled, to determine what the default variant should be. If there is no allocation, then None is set as the value of the variant in the EvaluationEvent. - :param FeatureFlag feature_flag: Feature flag object. :param EvaluationEvent evaluation_event: Evaluation event object. """ - if not feature_flag.allocation: + if not evaluation_event.feature.allocation: evaluation_event.enabled = True return FeatureManager._check_variant_override( - feature_flag.variants, feature_flag.allocation.default_when_enabled, True, evaluation_event + evaluation_event.feature.variants, + evaluation_event.feature.allocation.default_when_enabled, + True, + evaluation_event, ) @staticmethod @@ -109,16 +113,15 @@ def _is_targeted(context_id): return (context_marker / (2**32 - 1)) * 100 - def _assign_variant(self, feature_flag, targeting_context, evaluation_event): + def _assign_variant(self, targeting_context, evaluation_event): """ Assign a variant to the user based on the allocation. - :param FeatureFlag feature_flag: Feature flag object. :param TargetingContext targeting_context: Targeting context. :param EvaluationEvent evaluation_event: Evaluation event object. :return: Variant name. """ - evaluation_event.feature = feature_flag + feature_flag = evaluation_event.feature if not feature_flag.variants or not feature_flag.allocation: return None if feature_flag.allocation.user and targeting_context.user_id: @@ -228,8 +231,8 @@ async def get_variant(self, feature_flag_id, *args, **kwargs): result = await self._check_feature(feature_flag_id, targeting_context, **kwargs) return result.variant - async def _check_feature_filters(self, feature_flag, evaluation_event, targeting_context, **kwargs): - feature_conditions = feature_flag.conditions + async def _check_feature_filters(self, evaluation_event, targeting_context, **kwargs): + feature_conditions = evaluation_event.feature.conditions feature_filters = feature_conditions.client_filters if len(feature_filters) == 0: @@ -245,7 +248,7 @@ async def _check_feature_filters(self, feature_flag, evaluation_event, targeting kwargs["user"] = targeting_context.user_id kwargs["groups"] = targeting_context.groups if filter_name not in self._filters: - raise ValueError(f"Feature flag {feature_flag.name} has unknown filter {filter_name}") + raise ValueError(f"Feature flag {evaluation_event.feature.name} has unknown filter {filter_name}") if feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL: if not await self._filters[filter_name].evaluate(feature_filter, **kwargs): evaluation_event.enabled = False @@ -254,10 +257,11 @@ async def _check_feature_filters(self, feature_flag, evaluation_event, targeting evaluation_event.enabled = True break - def _assign_allocation(self, feature_flag, evaluation_event, targeting_context, **kwargs): + def _assign_allocation(self, evaluation_event, targeting_context, **kwargs): + feature_flag = evaluation_event.feature if feature_flag.allocation and feature_flag.variants: default_enabled = evaluation_event.enabled - variant_name = self._assign_variant(feature_flag, targeting_context, evaluation_event, **kwargs) + variant_name = self._assign_variant(targeting_context, evaluation_event, **kwargs) evaluation_event.enabled = default_enabled if variant_name: FeatureManager._check_variant_override( @@ -269,11 +273,11 @@ def _assign_allocation(self, feature_flag, evaluation_event, targeting_context, variant_name = None if evaluation_event.enabled: - FeatureManager._check_default_enabled_variant(feature_flag, evaluation_event) + FeatureManager._check_default_enabled_variant(evaluation_event) if feature_flag.allocation: variant_name = feature_flag.allocation.default_when_enabled else: - FeatureManager._check_default_disabled_variant(feature_flag, evaluation_event) + FeatureManager._check_default_disabled_variant(evaluation_event) if feature_flag.allocation: variant_name = feature_flag.allocation.default_when_disabled evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) @@ -287,7 +291,6 @@ async def _check_feature(self, feature_flag_id, targeting_context, **kwargs): :return: True if the feature flag is enabled for the given context. :rtype: bool """ - evaluation_event = EvaluationEvent(enabled=False) if self._copy is not self._configuration.get(FEATURE_MANAGEMENT_KEY): self._cache = {} self._copy = self._configuration.get(FEATURE_MANAGEMENT_KEY) @@ -298,6 +301,7 @@ async def _check_feature(self, feature_flag_id, targeting_context, **kwargs): else: feature_flag = self._cache.get(feature_flag_id) + evaluation_event = EvaluationEvent(feature_flag) if not feature_flag: logging.warning("Feature flag %s not found", feature_flag_id) # Unknown feature flags are disabled by default @@ -305,16 +309,16 @@ async def _check_feature(self, feature_flag_id, targeting_context, **kwargs): if not feature_flag.enabled: # Feature flags that are disabled are always disabled - FeatureManager._check_default_disabled_variant(feature_flag, evaluation_event) + FeatureManager._check_default_disabled_variant(evaluation_event) if feature_flag.allocation: variant_name = feature_flag.allocation.default_when_disabled evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) evaluation_event.feature = feature_flag return evaluation_event - await self._check_feature_filters(feature_flag, evaluation_event, targeting_context, **kwargs) + await self._check_feature_filters(evaluation_event, targeting_context, **kwargs) - self._assign_allocation(feature_flag, evaluation_event, targeting_context, **kwargs) + self._assign_allocation(evaluation_event, targeting_context, **kwargs) return evaluation_event def list_feature_flag_names(self): diff --git a/tests/test_send_telemetry_appinsights.py b/tests/test_send_telemetry_appinsights.py index 4ddd5d2..f708d8a 100644 --- a/tests/test_send_telemetry_appinsights.py +++ b/tests/test_send_telemetry_appinsights.py @@ -17,8 +17,8 @@ class TestSendTelemetryAppinsights: def test_send_telemetry_appinsights(self): - evaluation_event = EvaluationEvent() feature_flag = FeatureFlag.convert_from_json({"id": "TestFeature"}) + evaluation_event = EvaluationEvent(feature_flag) variant = Variant("TestVariant", None) evaluation_event.feature = feature_flag evaluation_event.enabled = True @@ -40,8 +40,8 @@ def test_send_telemetry_appinsights(self): assert mock_track_event.call_args[0][1]["VariantAssignmentReason"] == "DefaultWhenDisabled" def test_send_telemetry_appinsights_no_user(self): - evaluation_event = EvaluationEvent() feature_flag = FeatureFlag.convert_from_json({"id": "TestFeature"}) + evaluation_event = EvaluationEvent(feature_flag) variant = Variant("TestVariant", None) evaluation_event.feature = feature_flag evaluation_event.enabled = False @@ -62,8 +62,8 @@ def test_send_telemetry_appinsights_no_user(self): assert mock_track_event.call_args[0][1]["VariantAssignmentReason"] == "DefaultWhenDisabled" def test_send_telemetry_appinsights_no_variant(self): - evaluation_event = EvaluationEvent() feature_flag = FeatureFlag.convert_from_json({"id": "TestFeature"}) + evaluation_event = EvaluationEvent(feature_flag) evaluation_event.feature = feature_flag evaluation_event.enabled = True evaluation_event.user = "test_user" @@ -82,8 +82,8 @@ def test_send_telemetry_appinsights_no_variant(self): assert "Reason" not in mock_track_event.call_args[0][1] def test_send_telemetry_appinsights_no_import(self, caplog): - evaluation_event = EvaluationEvent() feature_flag = FeatureFlag.convert_from_json({"id": "TestFeature"}) + evaluation_event = EvaluationEvent(feature_flag) evaluation_event.feature = feature_flag evaluation_event.enabled = True From 3ff66b3075901104c9310bc550506338e2dafabb Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Wed, 10 Jul 2024 12:56:40 -0700 Subject: [PATCH 20/35] updating assign allocation logic --- featuremanagement/_featuremanager.py | 38 +++++++++----------- featuremanagement/aio/_featuremanager.py | 44 +++++++++++------------- 2 files changed, 37 insertions(+), 45 deletions(-) diff --git a/featuremanagement/_featuremanager.py b/featuremanagement/_featuremanager.py index 5d8cd6c..518de66 100644 --- a/featuremanagement/_featuremanager.py +++ b/featuremanagement/_featuremanager.py @@ -309,29 +309,25 @@ def _check_feature_filters(self, evaluation_event, targeting_context, **kwargs): def _assign_allocation(self, evaluation_event, targeting_context): feature_flag = evaluation_event.feature - if feature_flag.allocation and feature_flag.variants: - default_enabled = evaluation_event.enabled + if feature_flag.variants: + if not feature_flag.allocation: + if evaluation_event.enabled: + evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_ENABLED + return + evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_DISABLED + return + if not evaluation_event.enabled: + FeatureManager._check_default_disabled_variant(evaluation_event) + evaluation_event.variant = self._variant_name_to_variant(feature_flag, feature_flag.allocation.default_when_enabled) + return + variant_name = self._assign_variant(targeting_context, evaluation_event) - evaluation_event.enabled = default_enabled - if variant_name: - FeatureManager._check_variant_override( - feature_flag.variants, variant_name, evaluation_event.enabled, evaluation_event - ) - evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) - evaluation_event.feature = feature_flag + if not variant_name: + FeatureManager._check_default_enabled_variant(evaluation_event) + evaluation_event.variant = self._variant_name_to_variant(feature_flag, feature_flag.allocation.default_when_enabled) return - - variant_name = None - if evaluation_event.enabled: - FeatureManager._check_default_enabled_variant(evaluation_event) - if feature_flag.allocation: - variant_name = feature_flag.allocation.default_when_enabled - else: - FeatureManager._check_default_disabled_variant(evaluation_event) - if feature_flag.allocation: - variant_name = feature_flag.allocation.default_when_disabled - evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) - evaluation_event.feature = feature_flag + evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) + FeatureManager._check_variant_override(feature_flag.variants, variant_name, True, evaluation_event) def _check_feature(self, feature_flag_id, targeting_context, **kwargs): """ diff --git a/featuremanagement/aio/_featuremanager.py b/featuremanagement/aio/_featuremanager.py index a0b2be9..f7b0bf4 100644 --- a/featuremanagement/aio/_featuremanager.py +++ b/featuremanagement/aio/_featuremanager.py @@ -257,31 +257,27 @@ async def _check_feature_filters(self, evaluation_event, targeting_context, **kw evaluation_event.enabled = True break - def _assign_allocation(self, evaluation_event, targeting_context, **kwargs): + def _assign_allocation(self, evaluation_event, targeting_context): feature_flag = evaluation_event.feature - if feature_flag.allocation and feature_flag.variants: - default_enabled = evaluation_event.enabled - variant_name = self._assign_variant(targeting_context, evaluation_event, **kwargs) - evaluation_event.enabled = default_enabled - if variant_name: - FeatureManager._check_variant_override( - feature_flag.variants, variant_name, evaluation_event.enabled, evaluation_event - ) - evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) - evaluation_event.feature = feature_flag + if feature_flag.variants: + if not feature_flag.allocation: + if evaluation_event.enabled: + evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_ENABLED + return + evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_DISABLED return - - variant_name = None - if evaluation_event.enabled: - FeatureManager._check_default_enabled_variant(evaluation_event) - if feature_flag.allocation: - variant_name = feature_flag.allocation.default_when_enabled - else: - FeatureManager._check_default_disabled_variant(evaluation_event) - if feature_flag.allocation: - variant_name = feature_flag.allocation.default_when_disabled - evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) - evaluation_event.feature = feature_flag + if not evaluation_event.enabled: + FeatureManager._check_default_disabled_variant(evaluation_event) + evaluation_event.variant = self._variant_name_to_variant(feature_flag, feature_flag.allocation.default_when_enabled) + return + + variant_name = self._assign_variant(targeting_context, evaluation_event) + if not variant_name: + FeatureManager._check_default_enabled_variant(evaluation_event) + evaluation_event.variant = self._variant_name_to_variant(feature_flag, feature_flag.allocation.default_when_enabled) + return + evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) + FeatureManager._check_variant_override(feature_flag.variants, variant_name, True, evaluation_event) async def _check_feature(self, feature_flag_id, targeting_context, **kwargs): """ @@ -318,7 +314,7 @@ async def _check_feature(self, feature_flag_id, targeting_context, **kwargs): await self._check_feature_filters(evaluation_event, targeting_context, **kwargs) - self._assign_allocation(evaluation_event, targeting_context, **kwargs) + self._assign_allocation(evaluation_event, targeting_context) return evaluation_event def list_feature_flag_names(self): From 7297743600d12819618130f58fa05fc87d962049 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Wed, 10 Jul 2024 12:58:37 -0700 Subject: [PATCH 21/35] formatting --- featuremanagement/_featuremanager.py | 10 +++++++--- featuremanagement/aio/_featuremanager.py | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/featuremanagement/_featuremanager.py b/featuremanagement/_featuremanager.py index 518de66..043ec8f 100644 --- a/featuremanagement/_featuremanager.py +++ b/featuremanagement/_featuremanager.py @@ -318,13 +318,17 @@ def _assign_allocation(self, evaluation_event, targeting_context): return if not evaluation_event.enabled: FeatureManager._check_default_disabled_variant(evaluation_event) - evaluation_event.variant = self._variant_name_to_variant(feature_flag, feature_flag.allocation.default_when_enabled) + evaluation_event.variant = self._variant_name_to_variant( + feature_flag, feature_flag.allocation.default_when_enabled + ) return - + variant_name = self._assign_variant(targeting_context, evaluation_event) if not variant_name: FeatureManager._check_default_enabled_variant(evaluation_event) - evaluation_event.variant = self._variant_name_to_variant(feature_flag, feature_flag.allocation.default_when_enabled) + evaluation_event.variant = self._variant_name_to_variant( + feature_flag, feature_flag.allocation.default_when_enabled + ) return evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) FeatureManager._check_variant_override(feature_flag.variants, variant_name, True, evaluation_event) diff --git a/featuremanagement/aio/_featuremanager.py b/featuremanagement/aio/_featuremanager.py index f7b0bf4..4e75fa6 100644 --- a/featuremanagement/aio/_featuremanager.py +++ b/featuremanagement/aio/_featuremanager.py @@ -268,13 +268,17 @@ def _assign_allocation(self, evaluation_event, targeting_context): return if not evaluation_event.enabled: FeatureManager._check_default_disabled_variant(evaluation_event) - evaluation_event.variant = self._variant_name_to_variant(feature_flag, feature_flag.allocation.default_when_enabled) + evaluation_event.variant = self._variant_name_to_variant( + feature_flag, feature_flag.allocation.default_when_enabled + ) return - + variant_name = self._assign_variant(targeting_context, evaluation_event) if not variant_name: FeatureManager._check_default_enabled_variant(evaluation_event) - evaluation_event.variant = self._variant_name_to_variant(feature_flag, feature_flag.allocation.default_when_enabled) + evaluation_event.variant = self._variant_name_to_variant( + feature_flag, feature_flag.allocation.default_when_enabled + ) return evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) FeatureManager._check_variant_override(feature_flag.variants, variant_name, True, evaluation_event) From 7cb89350fb76310d44b543bcdeb4874839b8dcb0 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Fri, 12 Jul 2024 12:48:41 -0700 Subject: [PATCH 22/35] Adding Just Open Telemetry Support --- .../__init__.py | 0 .../_send_telemetry.py | 44 ++++++++++++++----- setup.py | 3 +- 3 files changed, 36 insertions(+), 11 deletions(-) rename featuremanagement/{azuremonitor => opentelemetry}/__init__.py (100%) rename featuremanagement/{azuremonitor => opentelemetry}/_send_telemetry.py (61%) diff --git a/featuremanagement/azuremonitor/__init__.py b/featuremanagement/opentelemetry/__init__.py similarity index 100% rename from featuremanagement/azuremonitor/__init__.py rename to featuremanagement/opentelemetry/__init__.py diff --git a/featuremanagement/azuremonitor/_send_telemetry.py b/featuremanagement/opentelemetry/_send_telemetry.py similarity index 61% rename from featuremanagement/azuremonitor/_send_telemetry.py rename to featuremanagement/opentelemetry/_send_telemetry.py index 2484b71..9075d60 100644 --- a/featuremanagement/azuremonitor/_send_telemetry.py +++ b/featuremanagement/opentelemetry/_send_telemetry.py @@ -3,18 +3,27 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # -------------------------------------------------------------------------- -import logging +from logging import getLogger, INFO from .._models import VariantAssignmentReason + +_event_logger = getLogger(__name__) + +try: + from opentelemetry.sdk._logs import LoggingHandler + HAS_OPENTELEMETRY_SDK = True +except ImportError: + HAS_OPENTELEMETRY_SDK = False + _event_logger.warning( + "opentelemetry-sdk is not installed. Telemetry will not be sent to Open Telemetry." + ) + try: from azure.monitor.events.extension import track_event as azure_monitor_track_event HAS_AZURE_MONITOR_EVENTS_EXTENSION = True except ImportError: HAS_AZURE_MONITOR_EVENTS_EXTENSION = False - logging.warning( - "azure-monitor-events-extension is not installed. Telemetry will not be sent to Application Insights." - ) FEATURE_NAME = "FeatureName" ENABLED = "Enabled" @@ -24,33 +33,48 @@ EVENT_NAME = "FeatureEvaluation" +_event_logger.propagate = False + +class _FeatureMnagementEventsExtension: + _initialized = False + + def _initialize(): + if not _FeatureMnagementEventsExtension._initialized: + _event_logger.addHandler(LoggingHandler()) + _event_logger.setLevel(INFO) + _FeatureMnagementEventsExtension._initialized = True def track_event(event_name, user, event_properties=None): """ Track an event with the specified name and properties. :param str event_name: The name of the event. + :param str user: The user ID to associate with the event. :param dict[str, str] event_properties: A dictionary of named string properties. """ - if not HAS_AZURE_MONITOR_EVENTS_EXTENSION: + if not HAS_OPENTELEMETRY_SDK: return if event_properties is None: event_properties = {} event_properties[TARGETING_ID] = user - azure_monitor_track_event(event_name, event_properties) + if HAS_AZURE_MONITOR_EVENTS_EXTENSION: + azure_monitor_track_event(event_name, event_properties) + return + _FeatureMnagementEventsExtension._initialize() + _event_logger.info(event_name, extra=event_properties) def publish_telemetry(evaluation_event): """ Publishes the telemetry for a feature's evaluation event. + + :param EvaluationEvent evaluation_event: The evaluation event to publish telemetry for. """ - if not HAS_AZURE_MONITOR_EVENTS_EXTENSION: + if not HAS_OPENTELEMETRY_SDK: return event = {} event[FEATURE_NAME] = evaluation_event.feature.name event[ENABLED] = str(evaluation_event.enabled) - if evaluation_event.user: - event[TARGETING_ID] = evaluation_event.user if evaluation_event.reason and evaluation_event.reason != VariantAssignmentReason.NONE: event[VARIANT] = evaluation_event.variant.name @@ -59,4 +83,4 @@ def publish_telemetry(evaluation_event): event["ETag"] = evaluation_event.feature.telemetry.metadata.get("etag", "") event["FeatureFlagReference"] = evaluation_event.feature.telemetry.metadata.get("feature_flag_reference", "") event["FeatureFlagId"] = evaluation_event.feature.telemetry.metadata.get("feature_flag_id", "") - azure_monitor_track_event(EVENT_NAME, event) + track_event(EVENT_NAME, evaluation_event.user, event) diff --git a/setup.py b/setup.py index f0a809a..03c0f80 100644 --- a/setup.py +++ b/setup.py @@ -59,6 +59,7 @@ python_requires=">=3.6", install_requires=[], extras_require={ - "AppInsights": ["azure-monitor-opentelemetry<2.0.0,>=1.3.0", "azure-monitor-events-extension<2.0.0"], + "OpenTelemetry": ["opentelemetry-sdk~=1.20"], + "AppInsightsEvents": ["azure-monitor-events-extension<2.0.0"], }, ) From c5dd07dd6e1ff5f65313fb5fe2541f6b4dd45b24 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Fri, 12 Jul 2024 12:53:45 -0700 Subject: [PATCH 23/35] Update _send_telemetry.py --- .../opentelemetry/_send_telemetry.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/featuremanagement/opentelemetry/_send_telemetry.py b/featuremanagement/opentelemetry/_send_telemetry.py index 9075d60..c918255 100644 --- a/featuremanagement/opentelemetry/_send_telemetry.py +++ b/featuremanagement/opentelemetry/_send_telemetry.py @@ -11,12 +11,11 @@ try: from opentelemetry.sdk._logs import LoggingHandler + HAS_OPENTELEMETRY_SDK = True except ImportError: HAS_OPENTELEMETRY_SDK = False - _event_logger.warning( - "opentelemetry-sdk is not installed. Telemetry will not be sent to Open Telemetry." - ) + _event_logger.warning("opentelemetry-sdk is not installed. Telemetry will not be sent to Open Telemetry.") try: from azure.monitor.events.extension import track_event as azure_monitor_track_event @@ -35,15 +34,21 @@ _event_logger.propagate = False + class _FeatureMnagementEventsExtension: _initialized = False - def _initialize(): + @staticmethod + def initialize(): + """ + Initializes the logger to use an OpenTelemetry logging handler, if not already initialized. + """ if not _FeatureMnagementEventsExtension._initialized: _event_logger.addHandler(LoggingHandler()) _event_logger.setLevel(INFO) _FeatureMnagementEventsExtension._initialized = True + def track_event(event_name, user, event_properties=None): """ Track an event with the specified name and properties. @@ -60,7 +65,7 @@ def track_event(event_name, user, event_properties=None): if HAS_AZURE_MONITOR_EVENTS_EXTENSION: azure_monitor_track_event(event_name, event_properties) return - _FeatureMnagementEventsExtension._initialize() + _FeatureMnagementEventsExtension.initialize() _event_logger.info(event_name, extra=event_properties) From 765f38e54d9f70014bedc379ad01777bcb8965dc Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Mon, 15 Jul 2024 15:41:07 -0700 Subject: [PATCH 24/35] review items --- featuremanagement/_featuremanager.py | 39 ++++++++++--------- .../opentelemetry/_send_telemetry.py | 15 +++---- .../feature_variant_sample_with_telemetry.py | 2 +- tests/test_send_telemetry_appinsights.py | 37 +++++++++++------- 4 files changed, 51 insertions(+), 42 deletions(-) diff --git a/featuremanagement/_featuremanager.py b/featuremanagement/_featuremanager.py index 043ec8f..dde9686 100644 --- a/featuremanagement/_featuremanager.py +++ b/featuremanagement/_featuremanager.py @@ -160,38 +160,45 @@ def _is_targeted(context_id): return (context_marker / (2**32 - 1)) * 100 - def _assign_variant(self, targeting_context, evaluation_event): + def _assign_variant(self, feature_flag, targeting_context, evaluation_event): """ Assign a variant to the user based on the allocation. :param TargetingContext targeting_context: Targeting context. :param EvaluationEvent evaluation_event: Evaluation event object. - :return: Variant name. """ feature = evaluation_event.feature + variant_name = None if not feature.variants or not feature.allocation: - return None + return if feature.allocation.user and targeting_context.user_id: for user_allocation in feature.allocation.user: if targeting_context.user_id in user_allocation.users: evaluation_event.reason = VariantAssignmentReason.USER - return user_allocation.variant - if feature.allocation.group and len(targeting_context.groups) > 0: + variant_name = user_allocation.variant + elif feature.allocation.group and len(targeting_context.groups) > 0: for group_allocation in feature.allocation.group: for group in targeting_context.groups: if group in group_allocation.groups: evaluation_event.reason = VariantAssignmentReason.GROUP - return group_allocation.variant - if feature.allocation.percentile: + variant_name = group_allocation.variant + elif feature.allocation.percentile: context_id = targeting_context.user_id + "\n" + feature.allocation.seed box = self._is_targeted(context_id) for percentile_allocation in feature.allocation.percentile: if box == 100 and percentile_allocation.percentile_to == 100: - return percentile_allocation.variant + variant_name = percentile_allocation.variant if percentile_allocation.percentile_from <= box < percentile_allocation.percentile_to: evaluation_event.reason = VariantAssignmentReason.PERCENTILE - return percentile_allocation.variant - return None + variant_name = percentile_allocation.variant + if not variant_name: + FeatureManager._check_default_enabled_variant(evaluation_event) + evaluation_event.variant = self._variant_name_to_variant( + feature_flag, feature_flag.allocation.default_when_enabled + ) + return + evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) + FeatureManager._check_variant_override(feature_flag.variants, variant_name, True, evaluation_event) def _variant_name_to_variant(self, feature_flag, variant_name): """ @@ -203,6 +210,8 @@ def _variant_name_to_variant(self, feature_flag, variant_name): """ if not feature_flag.variants: return None + if not variant_name: + return None for variant_reference in feature_flag.variants: if variant_reference.name == variant_name: configuration = variant_reference.configuration_value @@ -323,15 +332,7 @@ def _assign_allocation(self, evaluation_event, targeting_context): ) return - variant_name = self._assign_variant(targeting_context, evaluation_event) - if not variant_name: - FeatureManager._check_default_enabled_variant(evaluation_event) - evaluation_event.variant = self._variant_name_to_variant( - feature_flag, feature_flag.allocation.default_when_enabled - ) - return - evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) - FeatureManager._check_variant_override(feature_flag.variants, variant_name, True, evaluation_event) + self._assign_variant(feature_flag, targeting_context, evaluation_event) def _check_feature(self, feature_flag_id, targeting_context, **kwargs): """ diff --git a/featuremanagement/opentelemetry/_send_telemetry.py b/featuremanagement/opentelemetry/_send_telemetry.py index c918255..f2926d3 100644 --- a/featuremanagement/opentelemetry/_send_telemetry.py +++ b/featuremanagement/opentelemetry/_send_telemetry.py @@ -3,11 +3,11 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # -------------------------------------------------------------------------- -from logging import getLogger, INFO +from logging import getLogger as get_logger, INFO from .._models import VariantAssignmentReason -_event_logger = getLogger(__name__) +_event_logger = get_logger(__name__) try: from opentelemetry.sdk._logs import LoggingHandler @@ -35,7 +35,7 @@ _event_logger.propagate = False -class _FeatureMnagementEventsExtension: +class _FeatureManagementEventsExtension: _initialized = False @staticmethod @@ -43,10 +43,10 @@ def initialize(): """ Initializes the logger to use an OpenTelemetry logging handler, if not already initialized. """ - if not _FeatureMnagementEventsExtension._initialized: + if not _FeatureManagementEventsExtension._initialized: _event_logger.addHandler(LoggingHandler()) _event_logger.setLevel(INFO) - _FeatureMnagementEventsExtension._initialized = True + _FeatureManagementEventsExtension._initialized = True def track_event(event_name, user, event_properties=None): @@ -61,11 +61,12 @@ def track_event(event_name, user, event_properties=None): return if event_properties is None: event_properties = {} - event_properties[TARGETING_ID] = user + if user: + event_properties[TARGETING_ID] = user if HAS_AZURE_MONITOR_EVENTS_EXTENSION: azure_monitor_track_event(event_name, event_properties) return - _FeatureMnagementEventsExtension.initialize() + _FeatureManagementEventsExtension.initialize() _event_logger.info(event_name, extra=event_properties) diff --git a/samples/feature_variant_sample_with_telemetry.py b/samples/feature_variant_sample_with_telemetry.py index 79253bd..255506b 100644 --- a/samples/feature_variant_sample_with_telemetry.py +++ b/samples/feature_variant_sample_with_telemetry.py @@ -9,7 +9,7 @@ import sys from random_filter import RandomFilter from featuremanagement import FeatureManager -from featuremanagement.azuremonitor import publish_telemetry +from featuremanagement.opentelemetry import publish_telemetry try: from azure.monitor.opentelemetry import configure_azure_monitor diff --git a/tests/test_send_telemetry_appinsights.py b/tests/test_send_telemetry_appinsights.py index f708d8a..317b528 100644 --- a/tests/test_send_telemetry_appinsights.py +++ b/tests/test_send_telemetry_appinsights.py @@ -6,11 +6,12 @@ import sys import logging +from unittest import mock from importlib import reload from unittest.mock import patch import pytest from featuremanagement import EvaluationEvent, FeatureFlag, Variant, VariantAssignmentReason -import featuremanagement.azuremonitor._send_telemetry +import featuremanagement.opentelemetry._send_telemetry @pytest.mark.usefixtures("caplog") @@ -26,9 +27,9 @@ def test_send_telemetry_appinsights(self): evaluation_event.variant = variant evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_DISABLED - with patch("featuremanagement.azuremonitor._send_telemetry.azure_monitor_track_event") as mock_track_event: + with patch("featuremanagement.opentelemetry._send_telemetry.azure_monitor_track_event") as mock_track_event: # This is called like this so we can override the track_event function - featuremanagement.azuremonitor._send_telemetry.publish_telemetry( # pylint: disable=protected-access + featuremanagement.opentelemetry._send_telemetry.publish_telemetry( # pylint: disable=protected-access evaluation_event ) mock_track_event.assert_called_once() @@ -48,9 +49,9 @@ def test_send_telemetry_appinsights_no_user(self): evaluation_event.variant = variant evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_DISABLED - with patch("featuremanagement.azuremonitor._send_telemetry.azure_monitor_track_event") as mock_track_event: + with patch("featuremanagement.opentelemetry._send_telemetry.azure_monitor_track_event") as mock_track_event: # This is called like this so we can override the track_event function - featuremanagement.azuremonitor._send_telemetry.publish_telemetry( # pylint: disable=protected-access + featuremanagement.opentelemetry._send_telemetry.publish_telemetry( # pylint: disable=protected-access evaluation_event ) mock_track_event.assert_called_once() @@ -68,9 +69,9 @@ def test_send_telemetry_appinsights_no_variant(self): evaluation_event.enabled = True evaluation_event.user = "test_user" - with patch("featuremanagement.azuremonitor._send_telemetry.azure_monitor_track_event") as mock_track_event: + with patch("featuremanagement.opentelemetry._send_telemetry.azure_monitor_track_event") as mock_track_event: # This is called like this so we can override the track_event function - featuremanagement.azuremonitor._send_telemetry.publish_telemetry( # pylint: disable=protected-access + featuremanagement.opentelemetry._send_telemetry.publish_telemetry( # pylint: disable=protected-access evaluation_event ) mock_track_event.assert_called_once() @@ -81,16 +82,22 @@ def test_send_telemetry_appinsights_no_variant(self): assert "Variant" not in mock_track_event.call_args[0][1] assert "Reason" not in mock_track_event.call_args[0][1] - def test_send_telemetry_appinsights_no_import(self, caplog): + def test_send_telemetry_open_telemetry(self, caplog): feature_flag = FeatureFlag.convert_from_json({"id": "TestFeature"}) evaluation_event = EvaluationEvent(feature_flag) evaluation_event.feature = feature_flag evaluation_event.enabled = True - with patch.dict("sys.modules", {"azure.monitor.events.extension": None}): - reload(sys.modules["featuremanagement.azuremonitor._send_telemetry"]) - caplog.set_level(logging.WARNING) - featuremanagement.azuremonitor._send_telemetry.publish_telemetry( # pylint: disable=protected-access - evaluation_event - ) - assert "Telemetry will not be sent to Application Insights." in caplog.text + reload(sys.modules["featuremanagement.opentelemetry._send_telemetry"]) + with patch("featuremanagement.opentelemetry._send_telemetry._event_logger.info") as get_logger_mock: + caplog.set_level(logging.WARNING) + featuremanagement.opentelemetry._send_telemetry.publish_telemetry( # pylint: disable=protected-access + evaluation_event + ) + get_logger_mock.assert_called_once() + assert get_logger_mock.call_args[0][0] == "FeatureEvaluation" + assert get_logger_mock.call_args[1]["extra"]["FeatureName"] == "TestFeature" + assert get_logger_mock.call_args[1]["extra"]["Enabled"] == "True" + assert "TargetingId" not in get_logger_mock.call_args[1]["extra"] + assert "Variant" not in get_logger_mock.call_args[1]["extra"] + assert "VariantAssignmentReason" not in get_logger_mock.call_args[1]["extra"] From b086f3b249a745659ff3f560caef4e21d6630e7c Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Mon, 15 Jul 2024 15:52:52 -0700 Subject: [PATCH 25/35] Update test_send_telemetry_appinsights.py --- tests/test_send_telemetry_appinsights.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_send_telemetry_appinsights.py b/tests/test_send_telemetry_appinsights.py index 317b528..499eb4a 100644 --- a/tests/test_send_telemetry_appinsights.py +++ b/tests/test_send_telemetry_appinsights.py @@ -6,7 +6,6 @@ import sys import logging -from unittest import mock from importlib import reload from unittest.mock import patch import pytest From ca41bd5c9ef70d92290888a3bb7f79f91e12b901 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Tue, 16 Jul 2024 10:02:37 -0700 Subject: [PATCH 26/35] review comments --- featuremanagement/_featuremanager.py | 1 + featuremanagement/aio/_featuremanager.py | 71 +++++++++++-------- featuremanagement/opentelemetry/__init__.py | 1 - .../opentelemetry/_send_telemetry.py | 5 +- .../feature_variant_sample_with_telemetry.py | 2 +- tests/test_send_telemetry_appinsights.py | 1 - 6 files changed, 44 insertions(+), 37 deletions(-) diff --git a/featuremanagement/_featuremanager.py b/featuremanagement/_featuremanager.py index dde9686..f783131 100644 --- a/featuremanagement/_featuremanager.py +++ b/featuremanagement/_featuremanager.py @@ -142,6 +142,7 @@ def _check_variant_override(variants, default_variant_name, status, evaluation_e """ if not variants or not default_variant_name: evaluation_event.enabled = status + return for variant in variants: if variant.name == default_variant_name: if variant.status_override == "Enabled": diff --git a/featuremanagement/aio/_featuremanager.py b/featuremanagement/aio/_featuremanager.py index 4e75fa6..c7831c2 100644 --- a/featuremanagement/aio/_featuremanager.py +++ b/featuremanagement/aio/_featuremanager.py @@ -54,6 +54,7 @@ def _check_default_disabled_variant(evaluation_event): :param EvaluationEvent evaluation_event: Evaluation event object. """ + evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_DISABLED if not evaluation_event.feature.allocation: evaluation_event.enabled = False return @@ -72,6 +73,7 @@ def _check_default_enabled_variant(evaluation_event): :param EvaluationEvent evaluation_event: Evaluation event object. """ + evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_ENABLED if not evaluation_event.feature.allocation: evaluation_event.enabled = True return @@ -113,38 +115,45 @@ def _is_targeted(context_id): return (context_marker / (2**32 - 1)) * 100 - def _assign_variant(self, targeting_context, evaluation_event): + def _assign_variant(self, feature_flag, targeting_context, evaluation_event): """ Assign a variant to the user based on the allocation. :param TargetingContext targeting_context: Targeting context. :param EvaluationEvent evaluation_event: Evaluation event object. - :return: Variant name. """ - feature_flag = evaluation_event.feature - if not feature_flag.variants or not feature_flag.allocation: - return None - if feature_flag.allocation.user and targeting_context.user_id: - for user_allocation in feature_flag.allocation.user: + feature = evaluation_event.feature + variant_name = None + if not feature.variants or not feature.allocation: + return + if feature.allocation.user and targeting_context.user_id: + for user_allocation in feature.allocation.user: if targeting_context.user_id in user_allocation.users: evaluation_event.reason = VariantAssignmentReason.USER - return user_allocation.variant - if feature_flag.allocation.group and targeting_context.groups: - for group_allocation in feature_flag.allocation.group: + variant_name = user_allocation.variant + elif feature.allocation.group and len(targeting_context.groups) > 0: + for group_allocation in feature.allocation.group: for group in targeting_context.groups: if group in group_allocation.groups: evaluation_event.reason = VariantAssignmentReason.GROUP - return group_allocation.variant - if feature_flag.allocation.percentile: - context_id = targeting_context.user_id + "\n" + feature_flag.allocation.seed + variant_name = group_allocation.variant + elif feature.allocation.percentile: + context_id = targeting_context.user_id + "\n" + feature.allocation.seed box = self._is_targeted(context_id) - for percentile_allocation in feature_flag.allocation.percentile: + for percentile_allocation in feature.allocation.percentile: if box == 100 and percentile_allocation.percentile_to == 100: - return percentile_allocation.variant + variant_name = percentile_allocation.variant if percentile_allocation.percentile_from <= box < percentile_allocation.percentile_to: evaluation_event.reason = VariantAssignmentReason.PERCENTILE - return percentile_allocation.variant - return None + variant_name = percentile_allocation.variant + if not variant_name: + FeatureManager._check_default_enabled_variant(evaluation_event) + evaluation_event.variant = self._variant_name_to_variant( + feature_flag, feature_flag.allocation.default_when_enabled + ) + return + evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) + FeatureManager._check_variant_override(feature_flag.variants, variant_name, True, evaluation_event) def _variant_name_to_variant(self, feature_flag, variant_name): """ @@ -156,6 +165,8 @@ def _variant_name_to_variant(self, feature_flag, variant_name): """ if not feature_flag.variants: return None + if not variant_name: + return None for variant_reference in feature_flag.variants: if variant_reference.name == variant_name: configuration = variant_reference.configuration_value @@ -223,16 +234,22 @@ async def get_variant(self, feature_flag_id, *args, **kwargs): """ Determine the variant for the given context. - :param str feature_flag_id: Name of the feature flag. - :return: Name of the variant. - :rtype: str + :param str feature_flag_id: Name of the feature flag + :keyword TargetingContext targeting_context: Targeting context. + :return: Variant instance. + :rtype: Variant """ targeting_context = self._build_targeting_context(args) + result = await self._check_feature(feature_flag_id, targeting_context, **kwargs) + if self._on_feature_evaluated and result.feature.telemetry.enabled: + result.user = targeting_context.user_id + self._on_feature_evaluated(result) return result.variant async def _check_feature_filters(self, evaluation_event, targeting_context, **kwargs): - feature_conditions = evaluation_event.feature.conditions + feature_flag = evaluation_event.feature + feature_conditions = feature_flag.conditions feature_filters = feature_conditions.client_filters if len(feature_filters) == 0: @@ -248,7 +265,7 @@ async def _check_feature_filters(self, evaluation_event, targeting_context, **kw kwargs["user"] = targeting_context.user_id kwargs["groups"] = targeting_context.groups if filter_name not in self._filters: - raise ValueError(f"Feature flag {evaluation_event.feature.name} has unknown filter {filter_name}") + raise ValueError(f"Feature flag {feature_flag.name} has unknown filter {filter_name}") if feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL: if not await self._filters[filter_name].evaluate(feature_filter, **kwargs): evaluation_event.enabled = False @@ -273,15 +290,7 @@ def _assign_allocation(self, evaluation_event, targeting_context): ) return - variant_name = self._assign_variant(targeting_context, evaluation_event) - if not variant_name: - FeatureManager._check_default_enabled_variant(evaluation_event) - evaluation_event.variant = self._variant_name_to_variant( - feature_flag, feature_flag.allocation.default_when_enabled - ) - return - evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) - FeatureManager._check_variant_override(feature_flag.variants, variant_name, True, evaluation_event) + self._assign_variant(feature_flag, targeting_context, evaluation_event) async def _check_feature(self, feature_flag_id, targeting_context, **kwargs): """ diff --git a/featuremanagement/opentelemetry/__init__.py b/featuremanagement/opentelemetry/__init__.py index 2e82260..22099f5 100644 --- a/featuremanagement/opentelemetry/__init__.py +++ b/featuremanagement/opentelemetry/__init__.py @@ -3,7 +3,6 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # ------------------------------------------------------------------------- - from ._send_telemetry import publish_telemetry, track_event diff --git a/featuremanagement/opentelemetry/_send_telemetry.py b/featuremanagement/opentelemetry/_send_telemetry.py index f2926d3..eb5fd51 100644 --- a/featuremanagement/opentelemetry/_send_telemetry.py +++ b/featuremanagement/opentelemetry/_send_telemetry.py @@ -8,6 +8,7 @@ _event_logger = get_logger(__name__) +_event_logger.propagate = False try: from opentelemetry.sdk._logs import LoggingHandler @@ -32,8 +33,6 @@ EVENT_NAME = "FeatureEvaluation" -_event_logger.propagate = False - class _FeatureManagementEventsExtension: _initialized = False @@ -51,7 +50,7 @@ def initialize(): def track_event(event_name, user, event_properties=None): """ - Track an event with the specified name and properties. + Tracks an event with the specified name and properties. :param str event_name: The name of the event. :param str user: The user ID to associate with the event. diff --git a/samples/feature_variant_sample_with_telemetry.py b/samples/feature_variant_sample_with_telemetry.py index 255506b..da635ed 100644 --- a/samples/feature_variant_sample_with_telemetry.py +++ b/samples/feature_variant_sample_with_telemetry.py @@ -3,7 +3,6 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # -------------------------------------------------------------------------- - import json import os import sys @@ -11,6 +10,7 @@ from featuremanagement import FeatureManager from featuremanagement.opentelemetry import publish_telemetry + try: from azure.monitor.opentelemetry import configure_azure_monitor diff --git a/tests/test_send_telemetry_appinsights.py b/tests/test_send_telemetry_appinsights.py index 499eb4a..4d925cb 100644 --- a/tests/test_send_telemetry_appinsights.py +++ b/tests/test_send_telemetry_appinsights.py @@ -3,7 +3,6 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # -------------------------------------------------------------------------- - import sys import logging from importlib import reload From 0d769538e00e85266865e2c4501f1105f0226282 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Tue, 16 Jul 2024 10:22:29 -0700 Subject: [PATCH 27/35] fixing default --- featuremanagement/_featuremanager.py | 2 +- featuremanagement/aio/_featuremanager.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/featuremanagement/_featuremanager.py b/featuremanagement/_featuremanager.py index f783131..f5e275a 100644 --- a/featuremanagement/_featuremanager.py +++ b/featuremanagement/_featuremanager.py @@ -329,7 +329,7 @@ def _assign_allocation(self, evaluation_event, targeting_context): if not evaluation_event.enabled: FeatureManager._check_default_disabled_variant(evaluation_event) evaluation_event.variant = self._variant_name_to_variant( - feature_flag, feature_flag.allocation.default_when_enabled + feature_flag, feature_flag.allocation.default_when_disabled ) return diff --git a/featuremanagement/aio/_featuremanager.py b/featuremanagement/aio/_featuremanager.py index c7831c2..8e6fbfa 100644 --- a/featuremanagement/aio/_featuremanager.py +++ b/featuremanagement/aio/_featuremanager.py @@ -286,10 +286,9 @@ def _assign_allocation(self, evaluation_event, targeting_context): if not evaluation_event.enabled: FeatureManager._check_default_disabled_variant(evaluation_event) evaluation_event.variant = self._variant_name_to_variant( - feature_flag, feature_flag.allocation.default_when_enabled + feature_flag, feature_flag.allocation.default_when_disabled ) return - self._assign_variant(feature_flag, targeting_context, evaluation_event) async def _check_feature(self, feature_flag_id, targeting_context, **kwargs): From cead03309045e26cf0a17db1d1312315a5199173 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Wed, 17 Jul 2024 15:34:52 -0700 Subject: [PATCH 28/35] Removing Just Open Telemetry --- .../opentelemetry/_send_telemetry.py | 41 ++++--------------- setup.py | 3 +- 2 files changed, 8 insertions(+), 36 deletions(-) diff --git a/featuremanagement/opentelemetry/_send_telemetry.py b/featuremanagement/opentelemetry/_send_telemetry.py index eb5fd51..28af037 100644 --- a/featuremanagement/opentelemetry/_send_telemetry.py +++ b/featuremanagement/opentelemetry/_send_telemetry.py @@ -3,27 +3,18 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # -------------------------------------------------------------------------- -from logging import getLogger as get_logger, INFO +import logging from .._models import VariantAssignmentReason - -_event_logger = get_logger(__name__) -_event_logger.propagate = False - -try: - from opentelemetry.sdk._logs import LoggingHandler - - HAS_OPENTELEMETRY_SDK = True -except ImportError: - HAS_OPENTELEMETRY_SDK = False - _event_logger.warning("opentelemetry-sdk is not installed. Telemetry will not be sent to Open Telemetry.") - try: from azure.monitor.events.extension import track_event as azure_monitor_track_event HAS_AZURE_MONITOR_EVENTS_EXTENSION = True except ImportError: HAS_AZURE_MONITOR_EVENTS_EXTENSION = False + logging.warning( + "azure-monitor-events-extension is not installed. Telemetry will not be sent to Application Insights." + ) FEATURE_NAME = "FeatureName" ENABLED = "Enabled" @@ -34,20 +25,6 @@ EVENT_NAME = "FeatureEvaluation" -class _FeatureManagementEventsExtension: - _initialized = False - - @staticmethod - def initialize(): - """ - Initializes the logger to use an OpenTelemetry logging handler, if not already initialized. - """ - if not _FeatureManagementEventsExtension._initialized: - _event_logger.addHandler(LoggingHandler()) - _event_logger.setLevel(INFO) - _FeatureManagementEventsExtension._initialized = True - - def track_event(event_name, user, event_properties=None): """ Tracks an event with the specified name and properties. @@ -56,17 +33,13 @@ def track_event(event_name, user, event_properties=None): :param str user: The user ID to associate with the event. :param dict[str, str] event_properties: A dictionary of named string properties. """ - if not HAS_OPENTELEMETRY_SDK: + if not HAS_AZURE_MONITOR_EVENTS_EXTENSION: return if event_properties is None: event_properties = {} if user: event_properties[TARGETING_ID] = user - if HAS_AZURE_MONITOR_EVENTS_EXTENSION: - azure_monitor_track_event(event_name, event_properties) - return - _FeatureManagementEventsExtension.initialize() - _event_logger.info(event_name, extra=event_properties) + azure_monitor_track_event(event_name, event_properties) def publish_telemetry(evaluation_event): @@ -75,7 +48,7 @@ def publish_telemetry(evaluation_event): :param EvaluationEvent evaluation_event: The evaluation event to publish telemetry for. """ - if not HAS_OPENTELEMETRY_SDK: + if not HAS_AZURE_MONITOR_EVENTS_EXTENSION: return event = {} event[FEATURE_NAME] = evaluation_event.feature.name diff --git a/setup.py b/setup.py index 03c0f80..e1b38bd 100644 --- a/setup.py +++ b/setup.py @@ -59,7 +59,6 @@ python_requires=">=3.6", install_requires=[], extras_require={ - "OpenTelemetry": ["opentelemetry-sdk~=1.20"], - "AppInsightsEvents": ["azure-monitor-events-extension<2.0.0"], + "AzureMonitor": ["azure-monitor-events-extension<2.0.0"], }, ) From ade2027e45d7115d76ba245c4a9ab74b70018302 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Thu, 18 Jul 2024 14:01:40 -0700 Subject: [PATCH 29/35] Removing open telemetry test --- tests/test_send_telemetry_appinsights.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/tests/test_send_telemetry_appinsights.py b/tests/test_send_telemetry_appinsights.py index 4d925cb..eb93d89 100644 --- a/tests/test_send_telemetry_appinsights.py +++ b/tests/test_send_telemetry_appinsights.py @@ -3,9 +3,6 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # -------------------------------------------------------------------------- -import sys -import logging -from importlib import reload from unittest.mock import patch import pytest from featuremanagement import EvaluationEvent, FeatureFlag, Variant, VariantAssignmentReason @@ -79,23 +76,3 @@ def test_send_telemetry_appinsights_no_variant(self): assert mock_track_event.call_args[0][1]["TargetingId"] == "test_user" assert "Variant" not in mock_track_event.call_args[0][1] assert "Reason" not in mock_track_event.call_args[0][1] - - def test_send_telemetry_open_telemetry(self, caplog): - feature_flag = FeatureFlag.convert_from_json({"id": "TestFeature"}) - evaluation_event = EvaluationEvent(feature_flag) - evaluation_event.feature = feature_flag - evaluation_event.enabled = True - with patch.dict("sys.modules", {"azure.monitor.events.extension": None}): - reload(sys.modules["featuremanagement.opentelemetry._send_telemetry"]) - with patch("featuremanagement.opentelemetry._send_telemetry._event_logger.info") as get_logger_mock: - caplog.set_level(logging.WARNING) - featuremanagement.opentelemetry._send_telemetry.publish_telemetry( # pylint: disable=protected-access - evaluation_event - ) - get_logger_mock.assert_called_once() - assert get_logger_mock.call_args[0][0] == "FeatureEvaluation" - assert get_logger_mock.call_args[1]["extra"]["FeatureName"] == "TestFeature" - assert get_logger_mock.call_args[1]["extra"]["Enabled"] == "True" - assert "TargetingId" not in get_logger_mock.call_args[1]["extra"] - assert "Variant" not in get_logger_mock.call_args[1]["extra"] - assert "VariantAssignmentReason" not in get_logger_mock.call_args[1]["extra"] From 96b96b238674b5620c938d7ff4859e13be9c5c82 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Thu, 18 Jul 2024 15:44:28 -0700 Subject: [PATCH 30/35] rename to azuremonitor --- .../{opentelemetry => azuremonitor}/__init__.py | 0 .../{opentelemetry => azuremonitor}/_send_telemetry.py | 8 ++++---- samples/feature_variant_sample_with_telemetry.py | 2 +- tests/test_send_telemetry_appinsights.py | 8 ++++---- 4 files changed, 9 insertions(+), 9 deletions(-) rename featuremanagement/{opentelemetry => azuremonitor}/__init__.py (100%) rename featuremanagement/{opentelemetry => azuremonitor}/_send_telemetry.py (85%) diff --git a/featuremanagement/opentelemetry/__init__.py b/featuremanagement/azuremonitor/__init__.py similarity index 100% rename from featuremanagement/opentelemetry/__init__.py rename to featuremanagement/azuremonitor/__init__.py diff --git a/featuremanagement/opentelemetry/_send_telemetry.py b/featuremanagement/azuremonitor/_send_telemetry.py similarity index 85% rename from featuremanagement/opentelemetry/_send_telemetry.py rename to featuremanagement/azuremonitor/_send_telemetry.py index 28af037..55f873f 100644 --- a/featuremanagement/opentelemetry/_send_telemetry.py +++ b/featuremanagement/azuremonitor/_send_telemetry.py @@ -58,7 +58,7 @@ def publish_telemetry(evaluation_event): event[VARIANT] = evaluation_event.variant.name event[REASON] = evaluation_event.reason.value - event["ETag"] = evaluation_event.feature.telemetry.metadata.get("etag", "") - event["FeatureFlagReference"] = evaluation_event.feature.telemetry.metadata.get("feature_flag_reference", "") - event["FeatureFlagId"] = evaluation_event.feature.telemetry.metadata.get("feature_flag_id", "") - track_event(EVENT_NAME, evaluation_event.user, event) + for metadata_key, metadata_value in evaluation_event.feature.telemetry.metadata.items(): + if metadata_key not in event: + event[metadata_key] = metadata_value + track_event(EVENT_NAME, evaluation_event.user, event_properties=event) diff --git a/samples/feature_variant_sample_with_telemetry.py b/samples/feature_variant_sample_with_telemetry.py index da635ed..679f40d 100644 --- a/samples/feature_variant_sample_with_telemetry.py +++ b/samples/feature_variant_sample_with_telemetry.py @@ -8,7 +8,7 @@ import sys from random_filter import RandomFilter from featuremanagement import FeatureManager -from featuremanagement.opentelemetry import publish_telemetry +from featuremanagement.azuremonitor import publish_telemetry try: diff --git a/tests/test_send_telemetry_appinsights.py b/tests/test_send_telemetry_appinsights.py index eb93d89..17fa78a 100644 --- a/tests/test_send_telemetry_appinsights.py +++ b/tests/test_send_telemetry_appinsights.py @@ -6,7 +6,7 @@ from unittest.mock import patch import pytest from featuremanagement import EvaluationEvent, FeatureFlag, Variant, VariantAssignmentReason -import featuremanagement.opentelemetry._send_telemetry +import featuremanagement.azuremonitor._send_telemetry @pytest.mark.usefixtures("caplog") @@ -24,7 +24,7 @@ def test_send_telemetry_appinsights(self): with patch("featuremanagement.opentelemetry._send_telemetry.azure_monitor_track_event") as mock_track_event: # This is called like this so we can override the track_event function - featuremanagement.opentelemetry._send_telemetry.publish_telemetry( # pylint: disable=protected-access + featuremanagement.azuremonitor._send_telemetry.publish_telemetry( # pylint: disable=protected-access evaluation_event ) mock_track_event.assert_called_once() @@ -46,7 +46,7 @@ def test_send_telemetry_appinsights_no_user(self): with patch("featuremanagement.opentelemetry._send_telemetry.azure_monitor_track_event") as mock_track_event: # This is called like this so we can override the track_event function - featuremanagement.opentelemetry._send_telemetry.publish_telemetry( # pylint: disable=protected-access + featuremanagement.azuremonitor._send_telemetry.publish_telemetry( # pylint: disable=protected-access evaluation_event ) mock_track_event.assert_called_once() @@ -66,7 +66,7 @@ def test_send_telemetry_appinsights_no_variant(self): with patch("featuremanagement.opentelemetry._send_telemetry.azure_monitor_track_event") as mock_track_event: # This is called like this so we can override the track_event function - featuremanagement.opentelemetry._send_telemetry.publish_telemetry( # pylint: disable=protected-access + featuremanagement.azuremonitor._send_telemetry.publish_telemetry( # pylint: disable=protected-access evaluation_event ) mock_track_event.assert_called_once() From 996e7303a4c3bd3c541e98202b310f363a7788fc Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Thu, 18 Jul 2024 16:06:06 -0700 Subject: [PATCH 31/35] Update test_send_telemetry_appinsights.py --- tests/test_send_telemetry_appinsights.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_send_telemetry_appinsights.py b/tests/test_send_telemetry_appinsights.py index 17fa78a..96ebbbb 100644 --- a/tests/test_send_telemetry_appinsights.py +++ b/tests/test_send_telemetry_appinsights.py @@ -22,7 +22,7 @@ def test_send_telemetry_appinsights(self): evaluation_event.variant = variant evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_DISABLED - with patch("featuremanagement.opentelemetry._send_telemetry.azure_monitor_track_event") as mock_track_event: + with patch("featuremanagement.azuremonitor._send_telemetry.azure_monitor_track_event") as mock_track_event: # This is called like this so we can override the track_event function featuremanagement.azuremonitor._send_telemetry.publish_telemetry( # pylint: disable=protected-access evaluation_event @@ -44,7 +44,7 @@ def test_send_telemetry_appinsights_no_user(self): evaluation_event.variant = variant evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_DISABLED - with patch("featuremanagement.opentelemetry._send_telemetry.azure_monitor_track_event") as mock_track_event: + with patch("featuremanagement.azuremonitor._send_telemetry.azure_monitor_track_event") as mock_track_event: # This is called like this so we can override the track_event function featuremanagement.azuremonitor._send_telemetry.publish_telemetry( # pylint: disable=protected-access evaluation_event @@ -64,7 +64,7 @@ def test_send_telemetry_appinsights_no_variant(self): evaluation_event.enabled = True evaluation_event.user = "test_user" - with patch("featuremanagement.opentelemetry._send_telemetry.azure_monitor_track_event") as mock_track_event: + with patch("featuremanagement.azuremonitor._send_telemetry.azure_monitor_track_event") as mock_track_event: # This is called like this so we can override the track_event function featuremanagement.azuremonitor._send_telemetry.publish_telemetry( # pylint: disable=protected-access evaluation_event From cfcc158895cdb57c5978c8fe470f31a730b99576 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Thu, 18 Jul 2024 16:13:08 -0700 Subject: [PATCH 32/35] Update feature_variant_sample_with_telemetry.py --- samples/feature_variant_sample_with_telemetry.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/samples/feature_variant_sample_with_telemetry.py b/samples/feature_variant_sample_with_telemetry.py index 679f40d..d877aff 100644 --- a/samples/feature_variant_sample_with_telemetry.py +++ b/samples/feature_variant_sample_with_telemetry.py @@ -8,7 +8,7 @@ import sys from random_filter import RandomFilter from featuremanagement import FeatureManager -from featuremanagement.azuremonitor import publish_telemetry +from featuremanagement.azuremonitor import publish_telemetry, track_event try: @@ -31,3 +31,6 @@ # Evaluate the feature flag for the user print(feature_manager.get_variant("TestVariants", "Adam").configuration) + +# Track an event +track_event("TestEvent", "Adam") From f6dddbdf8d260fe1d4301bcd3d432ca18841b08b Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Fri, 19 Jul 2024 11:32:34 -0700 Subject: [PATCH 33/35] Fixing Enabled = False usage with status override --- featuremanagement/_featuremanager.py | 3 +++ featuremanagement/aio/_featuremanager.py | 3 +++ tests/test_feature_variants.py | 4 +++- tests/test_feature_variants_async.py | 4 +++- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/featuremanagement/_featuremanager.py b/featuremanagement/_featuremanager.py index f5e275a..ebe0bf5 100644 --- a/featuremanagement/_featuremanager.py +++ b/featuremanagement/_featuremanager.py @@ -366,6 +366,9 @@ def _check_feature(self, feature_flag_id, targeting_context, **kwargs): variant_name = feature_flag.allocation.default_when_disabled evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) evaluation_event.feature = feature_flag + + # If a feature flag is disabled and override can't enable it + evaluation_event.enabled = False return evaluation_event self._check_feature_filters(evaluation_event, targeting_context, **kwargs) diff --git a/featuremanagement/aio/_featuremanager.py b/featuremanagement/aio/_featuremanager.py index 8e6fbfa..8004641 100644 --- a/featuremanagement/aio/_featuremanager.py +++ b/featuremanagement/aio/_featuremanager.py @@ -322,6 +322,9 @@ async def _check_feature(self, feature_flag_id, targeting_context, **kwargs): variant_name = feature_flag.allocation.default_when_disabled evaluation_event.variant = self._variant_name_to_variant(feature_flag, variant_name) evaluation_event.feature = feature_flag + + # If a feature flag is disabled and override can't enable it + evaluation_event.enabled = False return evaluation_event await self._check_feature_filters(evaluation_event, targeting_context, **kwargs) diff --git a/tests/test_feature_variants.py b/tests/test_feature_variants.py index 14c2b7e..979ac55 100644 --- a/tests/test_feature_variants.py +++ b/tests/test_feature_variants.py @@ -48,7 +48,9 @@ def test_basic_feature_variant_override_disabled(self): } } feature_manager = FeatureManager(feature_flags) - assert feature_manager.is_enabled("Alpha") + + # Enabled = False takes precedence over status_override + assert not feature_manager.is_enabled("Alpha") assert feature_manager.get_variant("Alpha").name == "Off" # method: is_enabled diff --git a/tests/test_feature_variants_async.py b/tests/test_feature_variants_async.py index a0b44a7..3deafd8 100644 --- a/tests/test_feature_variants_async.py +++ b/tests/test_feature_variants_async.py @@ -50,7 +50,9 @@ async def test_basic_feature_variant_override_disabled(self): } } feature_manager = FeatureManager(feature_flags) - assert await feature_manager.is_enabled("Alpha") + + # Enabled = False takes precedence over status_override + assert not await feature_manager.is_enabled("Alpha") assert (await feature_manager.get_variant("Alpha")).name == "Off" # method: is_enabled From b42b9c6db7eca1cee3db6adeb84f1f1f2a5c7aa8 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Mon, 22 Jul 2024 22:00:41 -0700 Subject: [PATCH 34/35] Removed extra false --- featuremanagement/_featuremanager.py | 1 - featuremanagement/aio/_featuremanager.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/featuremanagement/_featuremanager.py b/featuremanagement/_featuremanager.py index ebe0bf5..2a4a0ec 100644 --- a/featuremanagement/_featuremanager.py +++ b/featuremanagement/_featuremanager.py @@ -102,7 +102,6 @@ def _check_default_disabled_variant(evaluation_event): """ evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_DISABLED if not evaluation_event.feature.allocation: - evaluation_event.enabled = False return FeatureManager._check_variant_override( evaluation_event.feature.variants, diff --git a/featuremanagement/aio/_featuremanager.py b/featuremanagement/aio/_featuremanager.py index 8004641..ae4981b 100644 --- a/featuremanagement/aio/_featuremanager.py +++ b/featuremanagement/aio/_featuremanager.py @@ -56,7 +56,6 @@ def _check_default_disabled_variant(evaluation_event): """ evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_DISABLED if not evaluation_event.feature.allocation: - evaluation_event.enabled = False return FeatureManager._check_variant_override( evaluation_event.feature.variants, @@ -289,6 +288,7 @@ def _assign_allocation(self, evaluation_event, targeting_context): feature_flag, feature_flag.allocation.default_when_disabled ) return + self._assign_variant(feature_flag, targeting_context, evaluation_event) async def _check_feature(self, feature_flag_id, targeting_context, **kwargs): From 822e4cd56e07b4f4f246a8f7c174979af84f076a Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Mon, 22 Jul 2024 22:06:08 -0700 Subject: [PATCH 35/35] Removed extra enabled --- featuremanagement/_featuremanager.py | 1 - featuremanagement/aio/_featuremanager.py | 1 - 2 files changed, 2 deletions(-) diff --git a/featuremanagement/_featuremanager.py b/featuremanagement/_featuremanager.py index 2a4a0ec..3b3954a 100644 --- a/featuremanagement/_featuremanager.py +++ b/featuremanagement/_featuremanager.py @@ -120,7 +120,6 @@ def _check_default_enabled_variant(evaluation_event): """ evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_ENABLED if not evaluation_event.feature.allocation: - evaluation_event.enabled = True return FeatureManager._check_variant_override( evaluation_event.feature.variants, diff --git a/featuremanagement/aio/_featuremanager.py b/featuremanagement/aio/_featuremanager.py index ae4981b..0a8125b 100644 --- a/featuremanagement/aio/_featuremanager.py +++ b/featuremanagement/aio/_featuremanager.py @@ -74,7 +74,6 @@ def _check_default_enabled_variant(evaluation_event): """ evaluation_event.reason = VariantAssignmentReason.DEFAULT_WHEN_ENABLED if not evaluation_event.feature.allocation: - evaluation_event.enabled = True return FeatureManager._check_variant_override( evaluation_event.feature.variants,