Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 0 additions & 158 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,164 +268,6 @@ class MyCustomFilter(FeatureFilter):
...
```

### 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
Expand Down
3 changes: 1 addition & 2 deletions featuremanagement/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@
from ._featuremanager import FeatureManager
from ._featurefilters import FeatureFilter
from ._defaultfilters import TimeWindowFilter, TargetingFilter
from ._models._variant import Variant

from ._version import VERSION

__version__ = VERSION
__all__ = ["FeatureManager", "TimeWindowFilter", "TargetingFilter", "FeatureFilter", "Variant"]
__all__ = ["FeatureManager", "TimeWindowFilter", "TargetingFilter", "FeatureFilter"]
116 changes: 4 additions & 112 deletions featuremanagement/_featuremanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@
# license information.
# -------------------------------------------------------------------------
import logging
import hashlib
from collections.abc import Mapping
from ._defaultfilters import TimeWindowFilter, TargetingFilter
from ._featurefilters import FeatureFilter
from ._models._feature_flag import FeatureFlag
from ._models._variant import Variant


FEATURE_MANAGEMENT_KEY = "feature_management"
Expand Down Expand Up @@ -80,80 +78,6 @@ 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):
if not feature_flag.allocation:
return False
return FeatureManager._check_variant_override(
feature_flag.variants, feature_flag.allocation.default_when_disabled, False
)

@staticmethod
def _check_default_enabled_variant(feature_flag):
if not feature_flag.allocation:
return 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):
if not variants or not default_variant_name:
return status
for variant in variants:
if variant.name == default_variant_name:
if variant.status_override == "Enabled":
return True
if variant.status_override == "Disabled":
return False
return 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, **kwargs):
if not feature_flag.variants or not feature_flag.allocation:
return None
if feature_flag.allocation.user:
user = kwargs.get("user")
if user:
for user_allocation in feature_flag.allocation.user:
if user in user_allocation.users:
return user_allocation.variant
if feature_flag.allocation.group:
groups = kwargs.get("groups")
if groups:
for group_allocation in feature_flag.allocation.group:
for group in groups:
if group in group_allocation.groups:
return group_allocation.variant
if feature_flag.allocation.percentile:
user = kwargs.get("user", "")
context_id = user + "\n" + feature_flag.allocation.seed
box = self._is_targeted(context_id)
for percentile_allocation in feature_flag.allocation.percentile:
if box == 100 and percentile_allocation.percentile_to == 100:
return percentile_allocation.variant
if percentile_allocation.percentile_from <= box < percentile_allocation.percentile_to:
return percentile_allocation.variant
return None

def _variant_name_to_variant(self, feature_flag, variant_name):
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 is_enabled(self, feature_flag_id, **kwargs):
"""
Determine if the feature flag is enabled for the given context
Expand All @@ -165,17 +89,6 @@ def is_enabled(self, feature_flag_id, **kwargs):
"""
return self._check_feature(feature_flag_id, **kwargs)["enabled"]

def get_variant(self, feature_flag_id, **kwargs):
"""
Determine the variant for the given context

:param str feature_flag_id: Name of the feature flag
:paramtype feature_flag_id: str
:return: Name of the variant
:rtype: str
"""
return self._check_feature(feature_flag_id, **kwargs)["variant"]

def _check_feature(self, feature_flag_id, **kwargs):
"""
Determine if the feature flag is enabled for the given context
Expand All @@ -185,7 +98,7 @@ def _check_feature(self, feature_flag_id, **kwargs):
:return: True if the feature flag is enabled for the given context
:rtype: bool
"""
result = {"enabled": None, "variant": None}
result = {"enabled": None}
if self._copy is not self._configuration.get(FEATURE_MANAGEMENT_KEY):
self._cache = {}
self._copy = self._configuration.get(FEATURE_MANAGEMENT_KEY)
Expand All @@ -203,11 +116,9 @@ def _check_feature(self, feature_flag_id, **kwargs):

if not feature_flag.enabled:
# Feature flags that are disabled are always disabled
result["enabled"] = FeatureManager._check_default_disabled_variant(feature_flag)
if feature_flag.allocation:
variant_name = feature_flag.allocation.default_when_disabled
result["variant"] = self._variant_name_to_variant(feature_flag, variant_name)
return result
return {
"enabled": False,
}

feature_conditions = feature_flag.conditions
feature_filters = feature_conditions.client_filters
Expand All @@ -232,25 +143,6 @@ def _check_feature(self, feature_flag_id, **kwargs):
result["enabled"] = True
break

if feature_flag.allocation and feature_flag.variants:
variant_name = self._assign_variant(feature_flag, **kwargs)
if variant_name:
result["enabled"] = FeatureManager._check_variant_override(
feature_flag.variants, variant_name, result["enabled"]
)
result["variant"] = self._variant_name_to_variant(feature_flag, variant_name)
return result

variant_name = None
if result["enabled"]:
result["enabled"] = FeatureManager._check_default_enabled_variant(feature_flag)
if feature_flag.allocation:
variant_name = feature_flag.allocation.default_when_enabled
else:
result["enabled"] = FeatureManager._check_default_disabled_variant(feature_flag)
if feature_flag.allocation:
variant_name = feature_flag.allocation.default_when_disabled
result["variant"] = self._variant_name_to_variant(feature_flag, variant_name)
return result

def list_feature_flag_names(self):
Expand Down
Loading