diff --git a/CHANGELOG.md b/CHANGELOG.md index 9866269..3f8e6f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Release History +## 2.0.0b1 (Unreleased) + +* Adds support for Feature Variants. + ## 1.0.0 (06/26/2024) Updated version to 1.0.0. diff --git a/README.md b/README.md index 1fc3dc3..48cdaba 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Feature management provides a way to develop and expose application functionalit ## Examples * [Python Application](https://github.com/microsoft/FeatureManagement-Python/blob/main/samples/feature_flag_sample.py) +* [Python Application with Feature Variants](https://github.com/microsoft/FeatureManagement-Python/blob/main/samples/feature_variant_sample.py) * [Python Application with Azure App Configuration](https://github.com/microsoft/FeatureManagement-Python/blob/main/samples/feature_flag_with_azure_app_configuration_sample.py) * [Django Application](https://github.com/Azure/AppConfiguration/tree/main/examples/Python/python-django-webapp-sample) * [Flask Application](https://github.com/Azure/AppConfiguration/tree/main/examples/Python/python-flask-webapp-sample) diff --git a/docs/conf.py b/docs/conf.py index 7313798..67f104f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,7 +10,7 @@ project = "FeatureManagement" copyright = "2024, Microsoft" author = "Microsoft" -release = "1.0.0" +release = "2.0.0b1" # -- General configuration --------------------------------------------------- diff --git a/featuremanagement/__init__.py b/featuremanagement/__init__.py index d4ac79b..e101fda 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 TargetingContext +from ._models import FeatureFlag, Variant, TargetingContext from ._version import VERSION @@ -16,5 +16,7 @@ "TimeWindowFilter", "TargetingFilter", "FeatureFilter", + "FeatureFlag", + "Variant", "TargetingContext", ] diff --git a/featuremanagement/_featuremanager.py b/featuremanagement/_featuremanager.py index ff73887..3c1ff45 100644 --- a/featuremanagement/_featuremanager.py +++ b/featuremanagement/_featuremanager.py @@ -4,11 +4,12 @@ # license information. # ------------------------------------------------------------------------- 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, EvaluationEvent, TargetingContext +from ._models import FeatureFlag, Variant, EvaluationEvent, TargetingContext FEATURE_MANAGEMENT_KEY = "feature_management" @@ -88,6 +89,111 @@ def __init__(self, configuration, **kwargs): raise ValueError("Custom filter must be a subclass of FeatureFilter") self._filters[feature_filter.name] = feature_filter + @staticmethod + def _check_default_disabled_variant(feature_flag): + """ + 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 + """ + if not feature_flag.allocation: + return EvaluationEvent(enabled=False) + return FeatureManager._check_variant_override( + feature_flag.variants, feature_flag.allocation.default_when_disabled, False + ) + + @staticmethod + def _check_default_enabled_variant(feature_flag): + """ + 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 + """ + if not feature_flag.allocation: + return EvaluationEvent(enabled=True) + return FeatureManager._check_variant_override( + feature_flag.variants, feature_flag.allocation.default_when_enabled, True + ) + + @staticmethod + def _check_variant_override(variants, default_variant_name, status): + """ + 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 + """ + if not variants or not default_variant_name: + return EvaluationEvent(enabled=status) + for variant in variants: + if variant.name == default_variant_name: + if variant.status_override == "Enabled": + return EvaluationEvent(enabled=True) + if variant.status_override == "Disabled": + return EvaluationEvent(enabled=False) + return EvaluationEvent(enabled=status) + + @staticmethod + def _is_targeted(context_id): + """Determine if the user is targeted for the given context""" + hashed_context_id = hashlib.sha256(context_id.encode()).digest() + context_marker = int.from_bytes(hashed_context_id[:4], byteorder="little", signed=False) + + 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. + """ + 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: + 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: + 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) + 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 + + def _variant_name_to_variant(self, feature_flag, variant_name): + """ + Get the variant object from the variant name. + + :param FeatureFlag feature_flag: Feature flag object. + :param str variant_name: Name of the variant. + :return: Variant object. + """ + if not feature_flag.variants: + return None + for variant_reference in feature_flag.variants: + if variant_reference.name == variant_name: + configuration = variant_reference.configuration_value + if not configuration: + 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 @@ -126,6 +232,31 @@ def is_enabled(self, feature_flag_id, *args, **kwargs): result = self._check_feature(feature_flag_id, targeting_context, **kwargs) 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. + :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. + + :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 = self._check_feature(feature_flag_id, targeting_context, **kwargs) + return result.variant + def _check_feature_filters(self, feature_flag, targeting_context, **kwargs): feature_conditions = feature_flag.conditions feature_filters = feature_conditions.client_filters @@ -154,6 +285,30 @@ def _check_feature_filters(self, feature_flag, targeting_context, **kwargs): break return evaluation_event + def _assign_allocation(self, feature_flag, evaluation_event, targeting_context): + if feature_flag.allocation and feature_flag.variants: + variant_name = self._assign_variant(feature_flag, targeting_context) + if variant_name: + 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 + + 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, targeting_context, **kwargs): """ Determine if the feature flag is enabled for the given context. @@ -179,9 +334,16 @@ def _check_feature(self, feature_flag_id, targeting_context, **kwargs): if not feature_flag.enabled: # Feature flags that are disabled are always disabled - return EvaluationEvent(enabled=False) + 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 + + evaluation_event = self._check_feature_filters(feature_flag, targeting_context, **kwargs) - return self._check_feature_filters(feature_flag, targeting_context, **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 f63470c..5aba53f 100644 --- a/featuremanagement/_models/__init__.py +++ b/featuremanagement/_models/__init__.py @@ -4,9 +4,10 @@ # license information. # ------------------------------------------------------------------------- from ._feature_flag import FeatureFlag +from ._variant import Variant from ._evaluation_event import EvaluationEvent from ._targeting_context import TargetingContext __path__ = __import__("pkgutil").extend_path(__path__, __name__) # type: ignore -__all__ = ["FeatureFlag", "EvaluationEvent", "TargetingContext"] +__all__ = ["FeatureFlag", "Variant", "EvaluationEvent", "TargetingContext"] diff --git a/featuremanagement/_models/_allocation.py b/featuremanagement/_models/_allocation.py new file mode 100644 index 0000000..7e8e454 --- /dev/null +++ b/featuremanagement/_models/_allocation.py @@ -0,0 +1,192 @@ +# ------------------------------------------------------------------------ +# 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 +from ._constants import DEFAULT_WHEN_ENABLED, DEFAULT_WHEN_DISABLED, USER, GROUP, PERCENTILE, SEED + + +class Allocation: + """ + Represents an allocation configuration for a feature flag. + """ + + def __init__(self, feature_name): + self._default_when_enabled = None + self._default_when_disabled = None + self._user = [] + self._group = [] + self._percentile = [] + self._seed = "allocation\n" + feature_name + + @classmethod + def convert_from_json(cls, json, feature_name): + """ + Convert a JSON object to Allocation. + + :param json: JSON object + :type json: dict + :return: Allocation + :rtype: Allocation + """ + if not json: + return None + allocation = cls(feature_name) + allocation._default_when_enabled = json.get(DEFAULT_WHEN_ENABLED) + allocation._default_when_disabled = json.get(DEFAULT_WHEN_DISABLED) + allocation._user = [] + allocation._group = [] + allocation._percentile = [] + if USER in json: + allocations = json.get(USER) + for user_allocation in allocations: + allocation._user.append(UserAllocation(**user_allocation)) + if GROUP in json: + allocations = json.get(GROUP) + for group_allocation in allocations: + allocation._group.append(GroupAllocation(**group_allocation)) + if PERCENTILE in json: + allocations = json.get(PERCENTILE) + for percentile_allocation in allocations: + allocation._percentile.append(PercentileAllocation.convert_from_json(percentile_allocation)) + allocation._seed = json.get(SEED, allocation._seed) + return allocation + + @property + def default_when_enabled(self): + """ + Get the default variant when the feature flag is enabled. + + :return: Default variant when the feature flag is enabled. + :rtype: str + """ + return self._default_when_enabled + + @property + def default_when_disabled(self): + """ + Get the default variant when the feature flag is disabled. + + :return: Default variant when the feature flag is disabled. + :rtype: str + """ + return self._default_when_disabled + + @property + def user(self): + """ + Get the user allocations. + + :return: User allocations. + :rtype: list[UserAllocation] + """ + return self._user + + @property + def group(self): + """ + Get the group allocations. + + :return: Group allocations. + :rtype: list[GroupAllocation] + """ + return self._group + + @property + def percentile(self): + """ + Get the percentile allocations. + + :return: Percentile allocations. + :rtype: list[PercentileAllocation] + """ + return self._percentile + + @property + def seed(self): + """ + Get the seed for the allocation. + + :return: Seed for the allocation. + :rtype: str + """ + return self._seed + + +@dataclass +class UserAllocation: + """ + Represents a user allocation. + """ + + variant: str + users: list + + +@dataclass +class GroupAllocation: + """ + Represents a group allocation. + """ + + variant: str + groups: list + + +class PercentileAllocation: + """ + Represents a percentile allocation. + """ + + def __init__(self): + self._variant = None + self._percentile_from = None + self._percentile_to = None + + @classmethod + def convert_from_json(cls, json): + """ + Convert a JSON object to PercentileAllocation. + + :param dict json: JSON object. + :return: PercentileAllocation + :rtype: PercentileAllocation + """ + if not json: + return None + user_allocation = cls() + user_allocation._variant = json.get("variant") + user_allocation._percentile_from = json.get("from") + user_allocation._percentile_to = json.get("to") + return user_allocation + + @property + def variant(self): + """ + Get the variant for the allocation. + + :return: Variant for the allocation. + :rtype: str + """ + return self._variant + + @property + def percentile_from(self): + """ + Get the starting percentile for the allocation. + + :return: Starting percentile for the allocation. + :rtype: int + """ + return self._percentile_from + + @property + def percentile_to(self): + """ + Get the ending percentile for the allocation. + + :return: Ending percentile for the allocation. + :rtype: int + """ + return self._percentile_to diff --git a/featuremanagement/_models/_constants.py b/featuremanagement/_models/_constants.py index b6ad52a..3f6ca41 100644 --- a/featuremanagement/_models/_constants.py +++ b/featuremanagement/_models/_constants.py @@ -8,6 +8,8 @@ FEATURE_FLAG_ID = "id" FEATURE_FLAG_ENABLED = "enabled" FEATURE_FLAG_CONDITIONS = "conditions" +FEATURE_FLAG_ALLOCATION = "allocation" +FEATURE_FLAG_VARIANTS = "variants" # Conditions @@ -16,3 +18,17 @@ REQUIREMENT_TYPE_ANY = "Any" FEATURE_FLAG_CLIENT_FILTERS = "client_filters" FEATURE_FILTER_NAME = "name" + +# Allocation +DEFAULT_WHEN_ENABLED = "default_when_enabled" +DEFAULT_WHEN_DISABLED = "default_when_disabled" +USER = "user" +GROUP = "group" +PERCENTILE = "percentile" +SEED = "seed" + +# Variant Reference +VARIANT_REFERENCE_NAME = "name" +CONFIGURATION_VALUE = "configuration_value" +CONFIGURATION_REFERENCE = "configuration_reference" +STATUS_OVERRIDE = "status_override" diff --git a/featuremanagement/_models/_feature_flag.py b/featuremanagement/_models/_feature_flag.py index 5ae3b13..7a50336 100644 --- a/featuremanagement/_models/_feature_flag.py +++ b/featuremanagement/_models/_feature_flag.py @@ -4,10 +4,15 @@ # license information. # ------------------------------------------------------------------------- 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, FEATURE_FLAG_CONDITIONS, + FEATURE_FLAG_ALLOCATION, + FEATURE_FLAG_VARIANTS, ) @@ -20,6 +25,9 @@ def __init__(self): self._id = None self._enabled = False self._conditions = FeatureConditions() + self._allocation = None + self._variants = None + self._telemetry = Telemetry() @classmethod def convert_from_json(cls, json_value): @@ -44,6 +52,17 @@ def convert_from_json(cls, json_value): ) else: feature_flag._conditions = FeatureConditions() + feature_flag._allocation = Allocation.convert_from_json( + json_value.get(FEATURE_FLAG_ALLOCATION, None), feature_flag._id + ) + feature_flag._variants = None + if FEATURE_FLAG_VARIANTS in json_value: + variants = json_value.get(FEATURE_FLAG_VARIANTS) + 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 @@ -77,6 +96,36 @@ def conditions(self): """ return self._conditions + @property + def allocation(self): + """ + Get the allocation for the feature flag. + + :return: Allocation for the feature flag. + :rtype: Allocation + """ + return self._allocation + + @property + def variants(self): + """ + Get the variants for the feature flag. + + :return: Variants for the feature flag. + :rtype: list[VariantReference] + """ + return self._variants + + @property + def telemetry(self): + """ + Get the telemetry configuration 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(f"Invalid setting 'id' with value '{self._id}' for feature '{self._id}'.") diff --git a/featuremanagement/_models/_telemetry.py b/featuremanagement/_models/_telemetry.py new file mode 100644 index 0000000..55e3917 --- /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, field + + +@dataclass +class Telemetry: + """ + Represents the telemetry configuration for a feature flag. + """ + + enabled: bool = False + metadata: dict = field(default_factory=dict) diff --git a/featuremanagement/_models/_variant.py b/featuremanagement/_models/_variant.py new file mode 100644 index 0000000..8b489e1 --- /dev/null +++ b/featuremanagement/_models/_variant.py @@ -0,0 +1,34 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- + + +class Variant: + """ + A class representing a variant configuration assigned by a feature flag. + + :param str name: The name of the variant + :param dict configuration: The configuration of the variant. + """ + + def __init__(self, name, configuration): + self._name = name + self._configuration = configuration + + @property + def name(self): + """ + The name of the variant. + :rtype: str + """ + return self._name + + @property + def configuration(self): + """ + The configuration of the variant. + :rtype: dict + """ + return self._configuration diff --git a/featuremanagement/_models/_variant_reference.py b/featuremanagement/_models/_variant_reference.py new file mode 100644 index 0000000..5742b07 --- /dev/null +++ b/featuremanagement/_models/_variant_reference.py @@ -0,0 +1,78 @@ +# ------------------------------------------------------------------------ +# 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 +from ._constants import VARIANT_REFERENCE_NAME, CONFIGURATION_VALUE, CONFIGURATION_REFERENCE, STATUS_OVERRIDE + + +@dataclass +class VariantReference: + """ + Represents a variant reference. + """ + + def __init__(self): + self._name = None + self._configuration_value = None + self._configuration_reference = None + self._status_override = None + + @classmethod + def convert_from_json(cls, json): + """ + Convert a JSON object to VariantReference. + + :param dict json: JSON object + :return: VariantReference + :rtype: VariantReference + """ + if not json: + return None + variant_reference = cls() + variant_reference._name = json.get(VARIANT_REFERENCE_NAME) + variant_reference._configuration_value = json.get(CONFIGURATION_VALUE) + variant_reference._configuration_reference = json.get(CONFIGURATION_REFERENCE) + variant_reference._status_override = json.get(STATUS_OVERRIDE, None) + return variant_reference + + @property + def name(self): + """ + Get the name of the variant. + + :return: Name of the variant + :rtype: str + """ + return self._name + + @property + def configuration_value(self): + """ + Get the configuration value for the variant. + + :return: Configuration value for the variant. + :rtype: str + """ + return self._configuration_value + + @property + def configuration_reference(self): + """ + Get the configuration reference for the variant. + + :return: Configuration reference for the variant. + :rtype: str + """ + return self._configuration_reference + + @property + def status_override(self): + """ + Get the status override for the variant. + + :return: Status override for the variant. + :rtype: str + """ + return self._status_override diff --git a/featuremanagement/aio/_featuremanager.py b/featuremanagement/aio/_featuremanager.py index 3a2f915..2a5a7b1 100644 --- a/featuremanagement/aio/_featuremanager.py +++ b/featuremanagement/aio/_featuremanager.py @@ -5,6 +5,7 @@ # ------------------------------------------------------------------------- from collections.abc import Mapping import logging +import hashlib from typing import overload from ._defaultfilters import TimeWindowFilter, TargetingFilter from ._featurefilters import FeatureFilter @@ -16,7 +17,7 @@ _get_feature_flag, _list_feature_flag_names, ) -from .._models import EvaluationEvent, TargetingContext +from .._models import Variant, EvaluationEvent, TargetingContext class FeatureManager: @@ -41,6 +42,111 @@ def __init__(self, configuration, **kwargs): raise ValueError("Custom filter must be a subclass of FeatureFilter") self._filters[feature_filter.name] = feature_filter + @staticmethod + def _check_default_disabled_variant(feature_flag): + """ + 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 + """ + if not feature_flag.allocation: + return EvaluationEvent(enabled=False) + return FeatureManager._check_variant_override( + feature_flag.variants, feature_flag.allocation.default_when_disabled, False + ) + + @staticmethod + def _check_default_enabled_variant(feature_flag): + """ + 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 + """ + if not feature_flag.allocation: + return EvaluationEvent(enabled=True) + return FeatureManager._check_variant_override( + feature_flag.variants, feature_flag.allocation.default_when_enabled, True + ) + + @staticmethod + def _check_variant_override(variants, default_variant_name, status): + """ + 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 + """ + if not variants or not default_variant_name: + return EvaluationEvent(enabled=status) + for variant in variants: + if variant.name == default_variant_name: + if variant.status_override == "Enabled": + return EvaluationEvent(enabled=True) + if variant.status_override == "Disabled": + return EvaluationEvent(enabled=False) + return EvaluationEvent(enabled=status) + + @staticmethod + def _is_targeted(context_id): + """Determine if the user is targeted for the given context""" + hashed_context_id = hashlib.sha256(context_id.encode()).digest() + context_marker = int.from_bytes(hashed_context_id[:4], byteorder="little", signed=False) + + 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. + """ + 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: + 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: + 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) + 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 + + def _variant_name_to_variant(self, feature_flag, variant_name): + """ + Get the variant object from the variant name. + + :param FeatureFlag feature_flag: Feature flag object. + :param str variant_name: Name of the variant. + :return: Variant object. + """ + if not feature_flag.variants: + return None + for variant_reference in feature_flag.variants: + if variant_reference.name == variant_name: + configuration = variant_reference.configuration_value + if not configuration: + 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 @@ -77,6 +183,29 @@ async def is_enabled(self, feature_flag_id, *args, **kwargs): targeting_context = self._build_targeting_context(args) return (await self._check_feature(feature_flag_id, targeting_context, **kwargs)).enabled + @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, targeting_context, **kwargs): feature_conditions = feature_flag.conditions feature_filters = feature_conditions.client_filters @@ -106,6 +235,30 @@ async def _check_feature_filters(self, feature_flag, targeting_context, **kwargs 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) + 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(self, feature_flag_id, targeting_context, **kwargs): """ Determine if the feature flag is enabled for the given context. @@ -131,9 +284,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 - return EvaluationEvent(enabled=False) + 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 + + evaluation_event = await self._check_feature_filters(feature_flag, targeting_context, **kwargs) - return await self._check_feature_filters(feature_flag, targeting_context, **kwargs) + return self._assign_allocation(feature_flag, evaluation_event, targeting_context, **kwargs) def list_feature_flag_names(self): """ diff --git a/pyproject.toml b/pyproject.toml index 470fa3f..53aace7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ build-backend = "setuptools.build_meta" [project] name = "FeatureManagement" -version = "1.0.0" +version = "2.0.0b1" authors = [ { name="Microsoft Corporation", email="appconfig@microsoft.com" }, ] diff --git a/samples/feature_variant_sample.py b/samples/feature_variant_sample.py new file mode 100644 index 0000000..62d5068 --- /dev/null +++ b/samples/feature_variant_sample.py @@ -0,0 +1,25 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +import json +import os +import sys +from random_filter import RandomFilter +from featuremanagement import FeatureManager, TargetingContext + + +script_directory = os.path.dirname(os.path.abspath(sys.argv[0])) + +with open(script_directory + "/formatted_feature_flags.json", "r", encoding="utf-8") as f: + feature_flags = json.load(f) + +feature_manager = FeatureManager(feature_flags, feature_filters=[RandomFilter()]) + +print(feature_manager.is_enabled("TestVariants", TargetingContext(user_id="Adam"))) +print(feature_manager.get_variant("TestVariants", TargetingContext(user_id="Adam")).configuration) + +print(feature_manager.is_enabled("TestVariants", TargetingContext(user_id="Cass"))) +print(feature_manager.get_variant("TestVariants", TargetingContext(user_id="Cass")).configuration) diff --git a/tests/test_feature_variants.py b/tests/test_feature_variants.py new file mode 100644 index 0000000..14c2b7e --- /dev/null +++ b/tests/test_feature_variants.py @@ -0,0 +1,249 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +from featuremanagement import FeatureManager, FeatureFilter, TargetingContext + + +class TestFeatureVariants: + # method: is_enabled + def test_basic_feature_variant_override_enabled(self): + feature_flags = { + "feature_management": { + "feature_flags": [ + { + "id": "Alpha", + "enabled": True, + "variants": [ + {"name": "On", "status_override": "Disabled"}, + ], + "allocation": { + "default_when_enabled": "On", + }, + } + ] + } + } + feature_manager = FeatureManager(feature_flags) + assert not feature_manager.is_enabled("Alpha") + assert feature_manager.get_variant("Alpha").name == "On" + + # method: is_enabled + def test_basic_feature_variant_override_disabled(self): + feature_flags = { + "feature_management": { + "feature_flags": [ + { + "id": "Alpha", + "enabled": False, + "variants": [ + {"name": "Off", "status_override": "Enabled"}, + ], + "allocation": { + "default_when_disabled": "Off", + }, + } + ] + } + } + feature_manager = FeatureManager(feature_flags) + assert feature_manager.is_enabled("Alpha") + assert feature_manager.get_variant("Alpha").name == "Off" + + # method: is_enabled + def test_basic_feature_variant_no_override(self): + feature_flags = { + "feature_management": { + "feature_flags": [ + { + "id": "Alpha", + "enabled": False, + "variants": [ + {"name": "Off"}, + ], + "allocation": { + "default_when_disabled": "Off", + }, + } + ] + } + } + feature_manager = FeatureManager(feature_flags) + assert not feature_manager.is_enabled("Alpha") + assert feature_manager.get_variant("Alpha").name == "Off" + + # method: is_enabled + def test_basic_feature_variant_allocation_users(self): + feature_flags = { + "feature_management": { + "feature_flags": [ + { + "id": "Alpha", + "enabled": True, + "variants": [ + {"name": "Off", "status_override": "Enabled"}, + {"name": "On", "status_override": "Disabled"}, + ], + "allocation": { + "user": [{"variant": "On", "users": ["Adam"]}, {"variant": "Off", "users": ["Brittney"]}], + }, + "conditions": { + "client_filters": [ + { + "name": "AlwaysOnFilter", + "parameters": {}, + } + ] + }, + } + ] + } + } + 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", "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): + feature_flags = { + "feature_management": { + "feature_flags": [ + { + "id": "Alpha", + "enabled": True, + "variants": [ + {"name": "Off", "status_override": "Enabled"}, + {"name": "On", "status_override": "Disabled"}, + ], + "allocation": { + "group": [ + {"variant": "On", "groups": ["Group1"]}, + {"variant": "Off", "groups": ["Group2"]}, + ], + }, + "conditions": { + "client_filters": [ + { + "name": "AlwaysOnFilter", + "parameters": {}, + } + ] + }, + } + ] + } + } + 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", 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): + feature_flags = { + "feature_management": { + "feature_flags": [ + { + "id": "Alpha", + "enabled": True, + "variants": [ + {"name": "Off", "status_override": "Enabled"}, + {"name": "On", "status_override": "Disabled"}, + ], + "allocation": { + "percentile": [ + {"variant": "On", "from": 0, "to": 50}, + {"variant": "Off", "from": 50, "to": 100}, + ], + }, + "conditions": { + "client_filters": [ + { + "name": "AlwaysOnFilter", + "parameters": {}, + } + ] + }, + } + ] + } + } + 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", "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): + feature_flags = { + "feature_management": { + "feature_flags": [ + { + "id": "Alpha", + "enabled": True, + "variants": [ + {"name": "Off", "status_override": "Enabled"}, + {"name": "On", "status_override": "Disabled"}, + ], + "allocation": { + "percentile": [ + {"variant": "On", "from": 0, "to": 50}, + {"variant": "Off", "from": 50, "to": 100}, + ], + "seed": "test-seed2", + }, + "conditions": { + "client_filters": [ + { + "name": "AlwaysOnFilter", + "parameters": {}, + } + ] + }, + } + ] + } + } + 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", "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): + def evaluate(self, context, **kwargs): + return True diff --git a/tests/test_feature_variants_async.py b/tests/test_feature_variants_async.py new file mode 100644 index 0000000..a0b44a7 --- /dev/null +++ b/tests/test_feature_variants_async.py @@ -0,0 +1,255 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +from unittest import IsolatedAsyncioTestCase +from featuremanagement.aio import FeatureManager, FeatureFilter +from featuremanagement import TargetingContext + + +class TestFeatureVariantsAsync(IsolatedAsyncioTestCase): + # method: is_enabled + async def test_basic_feature_variant_override_enabled(self): + feature_flags = { + "feature_management": { + "feature_flags": [ + { + "id": "Alpha", + "enabled": True, + "variants": [ + {"name": "On", "status_override": "Disabled"}, + ], + "allocation": { + "default_when_enabled": "On", + }, + } + ] + } + } + feature_manager = FeatureManager(feature_flags) + assert not await feature_manager.is_enabled("Alpha") + assert (await feature_manager.get_variant("Alpha")).name == "On" + + # method: is_enabled + async def test_basic_feature_variant_override_disabled(self): + feature_flags = { + "feature_management": { + "feature_flags": [ + { + "id": "Alpha", + "enabled": False, + "variants": [ + {"name": "Off", "status_override": "Enabled"}, + ], + "allocation": { + "default_when_disabled": "Off", + }, + } + ] + } + } + feature_manager = FeatureManager(feature_flags) + assert await feature_manager.is_enabled("Alpha") + assert (await feature_manager.get_variant("Alpha")).name == "Off" + + # method: is_enabled + async def test_basic_feature_variant_no_override(self): + feature_flags = { + "feature_management": { + "feature_flags": [ + { + "id": "Alpha", + "enabled": False, + "variants": [ + {"name": "Off"}, + ], + "allocation": { + "default_when_disabled": "Off", + }, + } + ] + } + } + feature_manager = FeatureManager(feature_flags) + assert not await feature_manager.is_enabled("Alpha") + assert (await feature_manager.get_variant("Alpha")).name == "Off" + + # method: is_enabled + async def test_basic_feature_variant_allocation_users(self): + feature_flags = { + "feature_management": { + "feature_flags": [ + { + "id": "Alpha", + "enabled": True, + "variants": [ + {"name": "Off", "status_override": "Enabled"}, + {"name": "On", "status_override": "Disabled"}, + ], + "allocation": { + "user": [{"variant": "On", "users": ["Adam"]}, {"variant": "Off", "users": ["Brittney"]}], + }, + "conditions": { + "client_filters": [ + { + "name": "AlwaysOnFilter", + "parameters": {}, + } + ] + }, + } + ] + } + } + feature_manager = FeatureManager(feature_flags, feature_filters=[AlwaysOnFilter()]) + assert await feature_manager.is_enabled("Alpha") + assert (await feature_manager.get_variant("Alpha")) is None + assert not await feature_manager.is_enabled("Alpha", "Adam") + assert (await feature_manager.get_variant("Alpha", "Adam")).name == "On" + assert await feature_manager.is_enabled("Alpha", "Brittney") + assert (await feature_manager.get_variant("Alpha", "Brittney")).name == "Off" + assert await feature_manager.is_enabled("Alpha", "Charlie") + assert (await feature_manager.get_variant("Alpha", "Charlie")) is None + + # method: is_enabled + async def test_basic_feature_variant_allocation_groups(self): + feature_flags = { + "feature_management": { + "feature_flags": [ + { + "id": "Alpha", + "enabled": True, + "variants": [ + {"name": "Off", "status_override": "Enabled"}, + {"name": "On", "status_override": "Disabled"}, + ], + "allocation": { + "group": [ + {"variant": "On", "groups": ["Group1"]}, + {"variant": "Off", "groups": ["Group2"]}, + ], + }, + "conditions": { + "client_filters": [ + { + "name": "AlwaysOnFilter", + "parameters": {}, + } + ] + }, + } + ] + } + } + feature_manager = FeatureManager(feature_flags, feature_filters=[AlwaysOnFilter()]) + assert await feature_manager.is_enabled("Alpha") + assert (await feature_manager.get_variant("Alpha")) is None + assert not await feature_manager.is_enabled("Alpha", TargetingContext(user_id="Adam", groups=["Group1"])) + assert ( + await feature_manager.get_variant("Alpha", TargetingContext(user_id="Adam", groups=["Group1"])) + ).name == "On" + assert await feature_manager.is_enabled("Alpha", TargetingContext(user_id="Brittney", groups=["Group2"])) + assert ( + await feature_manager.get_variant("Alpha", TargetingContext(user_id="Brittney", groups=["Group2"])) + ).name == "Off" + assert await feature_manager.is_enabled("Alpha", TargetingContext(user_id="Charlie", groups=["Group3"])) + assert ( + await feature_manager.get_variant("Alpha", TargetingContext(user_id="Charlie", groups=["Group3"])) + ) is None + + # method: is_enabled + async def test_basic_feature_variant_allocation_percentile(self): + feature_flags = { + "feature_management": { + "feature_flags": [ + { + "id": "Alpha", + "enabled": True, + "variants": [ + {"name": "Off", "status_override": "Enabled"}, + {"name": "On", "status_override": "Disabled"}, + ], + "allocation": { + "percentile": [ + {"variant": "On", "from": 0, "to": 50}, + {"variant": "Off", "from": 50, "to": 100}, + ], + }, + "conditions": { + "client_filters": [ + { + "name": "AlwaysOnFilter", + "parameters": {}, + } + ] + }, + } + ] + } + } + feature_manager = FeatureManager(feature_flags, feature_filters=[AlwaysOnFilter()]) + assert await feature_manager.is_enabled("Alpha") + assert (await feature_manager.get_variant("Alpha")).name == "Off" + assert await feature_manager.is_enabled("Alpha", "Adam") + assert (await feature_manager.get_variant("Alpha", "Adam")).name == "Off" + assert not await feature_manager.is_enabled("Alpha", "Brittney") + assert (await feature_manager.get_variant("Alpha", "Brittney")).name == "On" + assert not await feature_manager.is_enabled("Alpha", TargetingContext(user_id="Brittney", groups=["Group1"])) + assert ( + await feature_manager.get_variant("Alpha", TargetingContext(user_id="Brittney", groups=["Group1"])) + ).name == "On" + assert await feature_manager.is_enabled("Alpha", "Cassidy") + assert (await feature_manager.get_variant("Alpha", "Cassidy")).name == "Off" + + # method: is_enabled + async def test_basic_feature_variant_allocation_percentile_seeded(self): + feature_flags = { + "feature_management": { + "feature_flags": [ + { + "id": "Alpha", + "enabled": True, + "variants": [ + {"name": "Off", "status_override": "Enabled"}, + {"name": "On", "status_override": "Disabled"}, + ], + "allocation": { + "percentile": [ + {"variant": "On", "from": 0, "to": 50}, + {"variant": "Off", "from": 50, "to": 100}, + ], + "seed": "test-seed2", + }, + "conditions": { + "client_filters": [ + { + "name": "AlwaysOnFilter", + "parameters": {}, + } + ] + }, + } + ] + } + } + feature_manager = FeatureManager(feature_flags, feature_filters=[AlwaysOnFilter()]) + assert await feature_manager.is_enabled("Alpha") + assert (await feature_manager.get_variant("Alpha")).name == "Off" + assert not await feature_manager.is_enabled("Alpha", "Allison") + assert (await feature_manager.get_variant("Alpha", "Allison")).name == "On" + assert await feature_manager.is_enabled("Alpha", "Bubbles") + assert (await feature_manager.get_variant("Alpha", "Bubbles")).name == "Off" + assert await feature_manager.is_enabled("Alpha", TargetingContext(user_id="Bubbles", groups=["Group1"])) + assert ( + await feature_manager.get_variant("Alpha", TargetingContext(user_id="Bubbles", groups=["Group1"])) + ).name == "Off" + assert await feature_manager.is_enabled("Alpha", "Cassidy") + assert (await feature_manager.get_variant("Alpha", "Cassidy")).name == "Off" + assert not await feature_manager.is_enabled("Alpha", "Dan") + assert (await feature_manager.get_variant("Alpha", "Dan")).name == "On" + + +class AlwaysOnFilter(FeatureFilter): + async def evaluate(self, context, **kwargs): + return True