From f1db4114a3bb9099305a91d200f68d2e49da463f Mon Sep 17 00:00:00 2001 From: Matthew Metcalf Date: Wed, 26 Jun 2024 12:24:23 -0700 Subject: [PATCH 1/2] Revert "Removing non-b1 features (#25)" This reverts commit e6f53c9b4d0262e2366336774739d92fde7b5c29. --- README.md | 158 +++++++++++ featuremanagement/__init__.py | 4 +- featuremanagement/_featuremanager.py | 168 +++++++++++- featuremanagement/_models/__init__.py | 3 +- featuremanagement/_models/_allocation.py | 192 +++++++++++++ featuremanagement/_models/_constants.py | 16 ++ featuremanagement/_models/_feature_flag.py | 49 ++++ featuremanagement/_models/_telemetry.py | 16 ++ featuremanagement/_models/_variant.py | 34 +++ .../_models/_variant_reference.py | 78 ++++++ featuremanagement/aio/_featuremanager.py | 166 +++++++++++- samples/feature_variant_sample.py | 25 ++ tests/test_feature_variants.py | 249 +++++++++++++++++ tests/test_feature_variants_async.py | 255 ++++++++++++++++++ 14 files changed, 1405 insertions(+), 8 deletions(-) create mode 100644 featuremanagement/_models/_allocation.py create mode 100644 featuremanagement/_models/_telemetry.py create mode 100644 featuremanagement/_models/_variant.py create mode 100644 featuremanagement/_models/_variant_reference.py create mode 100644 samples/feature_variant_sample.py create mode 100644 tests/test_feature_variants.py create mode 100644 tests/test_feature_variants_async.py diff --git a/README.md b/README.md index 1fc3dc3..a960900 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,164 @@ Feature management provides a way to develop and expose application functionalit * [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) +### Feature Variants + +When new features are added to an application, there may come a time when a feature has multiple different proposed design options. A common solution for deciding on a design is some form of A/B testing, which involves providing a different version of the feature to different segments of the user base and choosing a version based on user interaction. In this library, this functionality is enabled by representing different configurations of a feature with variants. + +Variants enable a feature flag to become more than a simple on/off flag. A variant represents a value of a feature flag that can be a string, a number, a boolean, or even a configuration object. A feature flag that declares variants should define under what circumstances each variant should be used, which is covered in greater detail in the [Allocating Variants](#allocating-variants) section. + +```python +class Variant(): + + @property + def name(self): + + @property + def configuration(self): +``` + +#### Getting Variants + +For each feature, a variant can be retrieved using `FeatureManager`'s `get_variant` method. The method returns a `Variant` object that contains the name and configuration of the variant. Once a variant is retrieved, the configuration of a variant can be used directly as JSON from the variant's `configuration` property. + +```python +feature_manager = FeatureManager(feature_flags) + +variant = feature_manager.get_variant("FeatureU") + +my_configuration = variant.configuration + +variant = feature_manager.get_variant("FeatureV") + +sub_configuration = variant.configuration["json_key"] +``` + +#### Defining Variants + +Each variant has two properties: a name and a configuration. The name is used to refer to a specific variant, and the configuration is the value of that variant. The configuration can be set using either the `configuration_reference` or `configuration_value` properties. `configuration_reference` is a string that references a configuration, this configuration is a key inside of the configuration object passed into `FeatureManager`. `configuration_value` is an inline configuration that can be a string, number, boolean, or json object. If both are specified, `configuration_value` is used. If neither are specified, the returned variant's `configuration` property will be `None`. + +A list of all possible variants is defined for each feature under the Variants property. + +```json +{ + "feature_management": { + "feature_flags": [ + { + "id": "FeatureU", + "variants": [ + { + "name": "VariantA", + "configuration_reference": "config1" + }, + { + "name": "VariantB", + "configuration_value": { + "name": "value" + } + } + ] + } + ] + } +} +``` + +#### Allocating Variants + +The process of allocating a feature's variants is determined by the `allocation` property of the feature. + +```json +"allocation": { + "default_when_enabled": "Small", + "default_when_disabled": "Small", + "user": [ + { + "variant": "Big", + "users": [ + "Marsha" + ] + } + ], + "group": [ + { + "variant": "Big", + "groups": [ + "Ring1" + ] + } + ], + "percentile": [ + { + "variant": "Big", + "from": 0, + "to": 10 + } + ], + "seed": "13973240" +}, +"variants": [ + { + "name": "Big", + "configuration_reference": "ShoppingCart:Big" + }, + { + "name": "Small", + "configuration_value": "300px" + } +] +``` + +The `allocation` setting of a feature flag has the following properties: + +| Property | Description | +| ---------------- | ---------------- | +| `default_when_disabled` | Specifies which variant should be used when a variant is requested while the feature is considered disabled. | +| `default_when_enabled` | Specifies which variant should be used when a variant is requested while the feature is considered enabled and no other variant was assigned to the user. | +| `user` | Specifies a variant and a list of users to whom that variant should be assigned. | +| `group` | Specifies a variant and a list of groups the current user has to be in for that variant to be assigned. | +| `percentile` | Specifies a variant and a percentage range the user's calculated percentage has to fit into for that variant to be assigned. | +| `seed` | The value which percentage calculations for `percentile` are based on. The percentage calculation for a specific user will be the same across all features if the same `seed` value is used. If no `seed` is specified, then a default seed is created based on the feature name. | + +In the above example, if the feature is not enabled, the feature manager will assign the variant marked as `default_when_disabled` to the current user, which is `Small` in this case. + +If the feature is enabled, the feature manager will check the `user`, `group`, and `percentile` allocations in that order to assign a variant. For this particular example, if the user being evaluated is named `Marsha`, in the group named `Ring1`, or the user happens to fall between the 0 and 10th percentile, then the specified variant is assigned to the user. In this case, all of these would return the `Big` variant. If none of these allocations match, the user is assigned the `default_when_enabled` variant, which is `Small`. + +Allocation logic is similar to the [Microsoft.Targeting](#microsoft-targeting) feature filter, but there are some parameters that are present in targeting that aren't in allocation, and vice versa. The outcomes of targeting and allocation are not related. + +### Overriding Enabled State with a Variant + +You can use variants to override the enabled state of a feature flag. This gives variants an opportunity to extend the evaluation of a feature flag. If a caller is checking whether a flag that has variants is enabled, the feature manager will check if the variant assigned to the current user is set up to override the result. This is done using the optional variant property `status_override`. By default, this property is set to `None`, which means the variant doesn't affect whether the flag is considered enabled or disabled. Setting `status_override` to `Enabled` allows the variant, when chosen, to override a flag to be enabled. Setting `status_override` to `Disabled` provides the opposite functionality, therefore disabling the flag when the variant is chosen. + +If you are using a feature flag with binary variants, the `status_override` property can be very helpful. It allows you to continue using `is_enabled` in your application, all while benefiting from the new features that come with variants, such as percentile allocation and seed. + +```json +"allocation": { + "percentile": [{ + "variant": "On", + "from": 10, + "to": 20 + }], + "default_when_enabled": "Off", + "seed": "Enhanced-Feature-Group" +}, +"variants": [ + { + "name": "On" + }, + { + "name": "Off", + "status_override": "Disabled" + } +], +"enabled_for": [ + { + "name": "AlwaysOn" + } +] +``` + +In the above example, the feature is enabled by the `AlwaysOn` filter. If the current user is in the calculated percentile range of 10 to 20, then the `On` variant is returned. Otherwise, the `Off` variant is returned and because `status_override` is equal to `Disabled`, the feature will now be considered disabled. + ## Contributing This project welcomes contributions and suggestions. Most contributions require you to agree to a 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/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 From 196740d02ac6baa682846c5c056a4a9912520065 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Wed, 26 Jun 2024 12:31:23 -0700 Subject: [PATCH 2/2] Updating beta version, fixing readme --- CHANGELOG.md | 4 ++ README.md | 159 +------------------------------------------------ docs/conf.py | 2 +- pyproject.toml | 2 +- 4 files changed, 7 insertions(+), 160 deletions(-) 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 a960900..48cdaba 100644 --- a/README.md +++ b/README.md @@ -13,168 +13,11 @@ 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) -### Feature Variants - -When new features are added to an application, there may come a time when a feature has multiple different proposed design options. A common solution for deciding on a design is some form of A/B testing, which involves providing a different version of the feature to different segments of the user base and choosing a version based on user interaction. In this library, this functionality is enabled by representing different configurations of a feature with variants. - -Variants enable a feature flag to become more than a simple on/off flag. A variant represents a value of a feature flag that can be a string, a number, a boolean, or even a configuration object. A feature flag that declares variants should define under what circumstances each variant should be used, which is covered in greater detail in the [Allocating Variants](#allocating-variants) section. - -```python -class Variant(): - - @property - def name(self): - - @property - def configuration(self): -``` - -#### Getting Variants - -For each feature, a variant can be retrieved using `FeatureManager`'s `get_variant` method. The method returns a `Variant` object that contains the name and configuration of the variant. Once a variant is retrieved, the configuration of a variant can be used directly as JSON from the variant's `configuration` property. - -```python -feature_manager = FeatureManager(feature_flags) - -variant = feature_manager.get_variant("FeatureU") - -my_configuration = variant.configuration - -variant = feature_manager.get_variant("FeatureV") - -sub_configuration = variant.configuration["json_key"] -``` - -#### Defining Variants - -Each variant has two properties: a name and a configuration. The name is used to refer to a specific variant, and the configuration is the value of that variant. The configuration can be set using either the `configuration_reference` or `configuration_value` properties. `configuration_reference` is a string that references a configuration, this configuration is a key inside of the configuration object passed into `FeatureManager`. `configuration_value` is an inline configuration that can be a string, number, boolean, or json object. If both are specified, `configuration_value` is used. If neither are specified, the returned variant's `configuration` property will be `None`. - -A list of all possible variants is defined for each feature under the Variants property. - -```json -{ - "feature_management": { - "feature_flags": [ - { - "id": "FeatureU", - "variants": [ - { - "name": "VariantA", - "configuration_reference": "config1" - }, - { - "name": "VariantB", - "configuration_value": { - "name": "value" - } - } - ] - } - ] - } -} -``` - -#### Allocating Variants - -The process of allocating a feature's variants is determined by the `allocation` property of the feature. - -```json -"allocation": { - "default_when_enabled": "Small", - "default_when_disabled": "Small", - "user": [ - { - "variant": "Big", - "users": [ - "Marsha" - ] - } - ], - "group": [ - { - "variant": "Big", - "groups": [ - "Ring1" - ] - } - ], - "percentile": [ - { - "variant": "Big", - "from": 0, - "to": 10 - } - ], - "seed": "13973240" -}, -"variants": [ - { - "name": "Big", - "configuration_reference": "ShoppingCart:Big" - }, - { - "name": "Small", - "configuration_value": "300px" - } -] -``` - -The `allocation` setting of a feature flag has the following properties: - -| Property | Description | -| ---------------- | ---------------- | -| `default_when_disabled` | Specifies which variant should be used when a variant is requested while the feature is considered disabled. | -| `default_when_enabled` | Specifies which variant should be used when a variant is requested while the feature is considered enabled and no other variant was assigned to the user. | -| `user` | Specifies a variant and a list of users to whom that variant should be assigned. | -| `group` | Specifies a variant and a list of groups the current user has to be in for that variant to be assigned. | -| `percentile` | Specifies a variant and a percentage range the user's calculated percentage has to fit into for that variant to be assigned. | -| `seed` | The value which percentage calculations for `percentile` are based on. The percentage calculation for a specific user will be the same across all features if the same `seed` value is used. If no `seed` is specified, then a default seed is created based on the feature name. | - -In the above example, if the feature is not enabled, the feature manager will assign the variant marked as `default_when_disabled` to the current user, which is `Small` in this case. - -If the feature is enabled, the feature manager will check the `user`, `group`, and `percentile` allocations in that order to assign a variant. For this particular example, if the user being evaluated is named `Marsha`, in the group named `Ring1`, or the user happens to fall between the 0 and 10th percentile, then the specified variant is assigned to the user. In this case, all of these would return the `Big` variant. If none of these allocations match, the user is assigned the `default_when_enabled` variant, which is `Small`. - -Allocation logic is similar to the [Microsoft.Targeting](#microsoft-targeting) feature filter, but there are some parameters that are present in targeting that aren't in allocation, and vice versa. The outcomes of targeting and allocation are not related. - -### Overriding Enabled State with a Variant - -You can use variants to override the enabled state of a feature flag. This gives variants an opportunity to extend the evaluation of a feature flag. If a caller is checking whether a flag that has variants is enabled, the feature manager will check if the variant assigned to the current user is set up to override the result. This is done using the optional variant property `status_override`. By default, this property is set to `None`, which means the variant doesn't affect whether the flag is considered enabled or disabled. Setting `status_override` to `Enabled` allows the variant, when chosen, to override a flag to be enabled. Setting `status_override` to `Disabled` provides the opposite functionality, therefore disabling the flag when the variant is chosen. - -If you are using a feature flag with binary variants, the `status_override` property can be very helpful. It allows you to continue using `is_enabled` in your application, all while benefiting from the new features that come with variants, such as percentile allocation and seed. - -```json -"allocation": { - "percentile": [{ - "variant": "On", - "from": 10, - "to": 20 - }], - "default_when_enabled": "Off", - "seed": "Enhanced-Feature-Group" -}, -"variants": [ - { - "name": "On" - }, - { - "name": "Off", - "status_override": "Disabled" - } -], -"enabled_for": [ - { - "name": "AlwaysOn" - } -] -``` - -In the above example, the feature is enabled by the `AlwaysOn` filter. If the current user is in the calculated percentile range of 10 to 20, then the `On` variant is returned. Otherwise, the `Off` variant is returned and because `status_override` is equal to `Disabled`, the feature will now be considered disabled. - ## Contributing This project welcomes contributions and suggestions. Most contributions require you to agree to a 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/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" }, ]