Skip to content

Commit 0e7b894

Browse files
feat: strategy variants (#265)
* feat: strategy variants * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix linter * add unit tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * add unit tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * lint * cleanup * cleanup * cleanup * cleanup * fix linter complaints * fix linter complaints * fix linter complaints * fix logic * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * formatting * improvements * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * bug fixes * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * remove constraint noise from test * fix tests * fix tests * fix ruff complaining * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix PR comments * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 15c9000 commit 0e7b894

File tree

15 files changed

+221
-67
lines changed

15 files changed

+221
-67
lines changed

UnleashClient/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,8 @@ def get_variant(self, feature_name: str, context: Optional[dict] = None) -> dict
469469
self.features[feature_name] = new_feature
470470

471471
# Use the feature's get_variant method to count the call
472-
return new_feature.get_variant(context)
472+
variant_check = new_feature.get_variant(context)
473+
return variant_check
473474
else:
474475
LOGGER.log(
475476
self.unleash_verbose_log_level,

UnleashClient/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
REQUEST_TIMEOUT = 30
77
REQUEST_RETRIES = 3
88
METRIC_LAST_SENT_TIME = "mlst"
9-
CLIENT_SPEC_VERSION = "4.2.2"
9+
CLIENT_SPEC_VERSION = "4.3.1"
1010

1111
# =Unleash=
1212
APPLICATION_HEADERS = {

UnleashClient/constraints/Constraint.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -226,10 +226,9 @@ def apply(self, context: dict = None) -> bool:
226226
constraint_check = self.check_semver_operators(
227227
context_value=context_value
228228
)
229-
else:
230-
# This is a special case in the client spec - so it's getting it's own handler here
231-
if self.operator is ConstraintOperators.NOT_IN: # noqa: PLR5501
232-
constraint_check = True
229+
# This is a special case in the client spec - so it's getting it's own handler here
230+
elif self.operator is ConstraintOperators.NOT_IN: # noqa: PLR5501
231+
constraint_check = True
233232

234233
except Exception as excep: # pylint: disable=broad-except
235234
LOGGER.info(

UnleashClient/deprecation_warnings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def strategy_v2xx_deprecation_check(strategies: list) -> None:
1010
for strategy in strategies:
1111
try:
1212
# Check if the __call__() method is overwritten (should only be true for custom strategies in v1.x or v2.x.
13-
if strategy.__call__ != Strategy.__call__:
13+
if strategy.__call__ != Strategy.__call__: # type:ignore
1414
warnings.warn(
1515
f"unleash-client-python v3.x.x requires overriding the execute() method instead of the __call__() method. Error in: {strategy.__name__}",
1616
DeprecationWarning,

UnleashClient/features/Feature.py

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# pylint: disable=invalid-name
2+
import copy
23
from typing import Dict, Optional, cast
34

45
from UnleashClient.constants import DISABLED_VARIATION
6+
from UnleashClient.strategies import EvaluationResult, Strategy
57
from UnleashClient.utils import LOGGER
68
from UnleashClient.variants import Variants
79

@@ -73,33 +75,16 @@ def _count_variant(self, variant_name: str) -> None:
7375
"""
7476
self.variant_counts[variant_name] = self.variant_counts.get(variant_name, 0) + 1
7577

76-
def is_enabled(
77-
self, context: dict = None, default_value: bool = False
78-
) -> bool: # pylint: disable=unused-argument
78+
def is_enabled(self, context: dict = None) -> bool:
7979
"""
8080
Checks if feature is enabled.
8181
8282
:param context: Context information
83-
:param default_value: Deprecated! Users should use the fallback_function on the main is_enabled() method.
8483
:return:
8584
"""
86-
flag_value = False
85+
evaluation_result = self._get_evaluation_result(context)
8786

88-
if self.enabled:
89-
try:
90-
if self.strategies:
91-
strategy_result = any(x.execute(context) for x in self.strategies)
92-
else:
93-
# If no strategies are present, should default to true. This isn't possible via UI.
94-
strategy_result = True
95-
96-
flag_value = strategy_result
97-
except Exception as strategy_except:
98-
LOGGER.warning("Error checking feature flag: %s", strategy_except)
99-
100-
self.increment_stats(flag_value)
101-
102-
LOGGER.info("Feature toggle status for feature %s: %s", self.name, flag_value)
87+
flag_value = evaluation_result.enabled
10388

10489
return flag_value
10590

@@ -110,19 +95,46 @@ def get_variant(self, context: dict = None) -> dict:
11095
:param context: Context information
11196
:return:
11297
"""
113-
variant = DISABLED_VARIATION
114-
is_feature_enabled = self.is_enabled(context)
115-
116-
if is_feature_enabled and self.variants is not None:
98+
evaluation_result = self._get_evaluation_result(context)
99+
is_feature_enabled = evaluation_result.enabled
100+
variant = evaluation_result.variant
101+
if variant is None or (is_feature_enabled and variant == DISABLED_VARIATION):
117102
try:
118-
variant = self.variants.get_variant(context)
119-
variant["enabled"] = is_feature_enabled
103+
LOGGER.debug("Getting variant from feature: %s", self.name)
104+
variant = (
105+
self.variants.get_variant(context, is_feature_enabled)
106+
if is_feature_enabled
107+
else copy.deepcopy(DISABLED_VARIATION)
108+
)
109+
120110
except Exception as variant_exception:
121111
LOGGER.warning("Error selecting variant: %s", variant_exception)
122112

123113
self._count_variant(cast(str, variant["name"]))
124114
return variant
125115

116+
def _get_evaluation_result(self, context: dict = None) -> EvaluationResult:
117+
strategy_result = EvaluationResult(False, None)
118+
if self.enabled:
119+
try:
120+
if self.strategies:
121+
enabled_strategy: Strategy = next(
122+
(x for x in self.strategies if x.execute(context)), None
123+
)
124+
if enabled_strategy is not None:
125+
strategy_result = enabled_strategy.get_result(context)
126+
127+
else:
128+
# If no strategies are present, should default to true. This isn't possible via UI.
129+
strategy_result = EvaluationResult(True, None)
130+
131+
except Exception as evaluation_except:
132+
LOGGER.warning("Error getting evaluation result: %s", evaluation_except)
133+
134+
self.increment_stats(strategy_result.enabled)
135+
LOGGER.info("%s evaluation result: %s", self.name, strategy_result)
136+
return strategy_result
137+
126138
@staticmethod
127139
def metrics_only_feature(feature_name: str):
128140
feature = Feature(feature_name, False, [])

UnleashClient/loader.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,15 @@ def _create_strategies(
3333
else:
3434
segment_provisioning = []
3535

36+
if "variants" in strategy.keys():
37+
variant_provisioning = strategy["variants"]
38+
else:
39+
variant_provisioning = []
40+
3641
feature_strategies.append(
3742
strategy_mapping[strategy["name"]](
3843
constraints=constraint_provisioning,
44+
variants=variant_provisioning,
3945
parameters=strategy_provisioning,
4046
global_segments=global_segments,
4147
segment_ids=segment_provisioning,

UnleashClient/strategies/RemoteAddress.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,8 @@ def apply(self, context: dict = None) -> bool:
6161
if context_ip == addr_or_range:
6262
return_value = True
6363
break
64-
else:
65-
if context_ip in addr_or_range: # noqa: PLR5501
66-
return_value = True
67-
break
64+
elif context_ip in addr_or_range: # noqa: PLR5501
65+
return_value = True
66+
break
6867

6968
return return_value

UnleashClient/strategies/Strategy.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
# pylint: disable=invalid-name,dangerous-default-value
22
import warnings
3-
from typing import Iterator
3+
from dataclasses import dataclass
4+
from typing import Iterator, Optional
45

56
from UnleashClient.constraints import Constraint
7+
from UnleashClient.variants import Variants
8+
9+
10+
@dataclass
11+
class EvaluationResult:
12+
enabled: bool
13+
variant: Optional[dict]
614

715

816
class Strategy:
@@ -15,6 +23,7 @@ class Strategy:
1523
- ``apply()`` - Your feature provisioning
1624
1725
:param constraints: List of 'constraints' objects derived from strategy section (...from feature section) of `/api/clients/features` Unleash server response.
26+
:param variants: List of 'variant' objects derived from strategy section (...from feature section) of `/api/clients/features` Unleash server response.
1827
:param parameters: The 'parameter' objects from the strategy section (...from feature section) of `/api/clients/features` Unleash server response.
1928
"""
2029

@@ -24,9 +33,11 @@ def __init__(
2433
parameters: dict = {},
2534
segment_ids: list = None,
2635
global_segments: dict = None,
36+
variants: list = None,
2737
) -> None:
2838
self.parameters = parameters
2939
self.constraints = constraints
40+
self.variants = variants or []
3041
self.segment_ids = segment_ids or []
3142
self.global_segments = global_segments or {}
3243
self.parsed_provisioning = self.load_provisioning()
@@ -56,6 +67,15 @@ def execute(self, context: dict = None) -> bool:
5667

5768
return flag_state
5869

70+
def get_result(self, context) -> EvaluationResult:
71+
enabled = self.execute(context)
72+
variant = None
73+
if enabled:
74+
variant = self.parsed_variants.get_variant(context, enabled)
75+
76+
result = EvaluationResult(enabled, variant)
77+
return result
78+
5979
@property
6080
def parsed_constraints(self) -> Iterator[Constraint]:
6181
for constraint_dict in self.constraints:
@@ -66,6 +86,14 @@ def parsed_constraints(self) -> Iterator[Constraint]:
6686
for constraint in segment["constraints"]:
6787
yield Constraint(constraint_dict=constraint)
6888

89+
@property
90+
def parsed_variants(self) -> Variants:
91+
return Variants(
92+
variants_list=self.variants,
93+
group_id=self.parameters.get("groupId"),
94+
is_feature_variants=False,
95+
)
96+
6997
def load_provisioning(self) -> list: # pylint: disable=no-self-use
7098
"""
7199
Loads strategy provisioning from Unleash feature flag configuration.

UnleashClient/strategies/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@
66
from .GradualRolloutSessionId import GradualRolloutSessionId
77
from .GradualRolloutUserId import GradualRolloutUserId
88
from .RemoteAddress import RemoteAddress
9-
from .Strategy import Strategy
9+
from .Strategy import EvaluationResult, Strategy
1010
from .UserWithId import UserWithId

UnleashClient/variants/Variants.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
11
# pylint: disable=invalid-name, too-few-public-methods
22
import copy
33
import random
4-
from typing import Dict # noqa: F401
4+
from typing import Dict, Optional # noqa: F401
55

66
from UnleashClient import utils
77
from UnleashClient.constants import DISABLED_VARIATION
88

99

1010
class Variants:
11-
def __init__(self, variants_list: list, feature_name: str) -> None:
11+
def __init__(
12+
self, variants_list: list, group_id: str, is_feature_variants: bool = True
13+
) -> None:
1214
"""
1315
Represents an A/B test
1416
1517
variants_list = From the strategy document.
1618
"""
1719
self.variants = variants_list
18-
self.feature_name = feature_name
20+
self.group_id = group_id
21+
self.is_feature_variants = is_feature_variants
1922

2023
def _apply_overrides(self, context: dict) -> dict:
2124
"""
@@ -60,28 +63,31 @@ def _get_seed(context: dict, stickiness_selector: str = "default") -> str:
6063
return seed
6164

6265
@staticmethod
63-
def _format_variation(variation: dict) -> dict:
66+
def _format_variation(variation: dict, flag_status: Optional[bool] = None) -> dict:
6467
formatted_variation = copy.deepcopy(variation)
6568
del formatted_variation["weight"]
6669
if "overrides" in formatted_variation:
6770
del formatted_variation["overrides"]
6871
if "stickiness" in formatted_variation:
6972
del formatted_variation["stickiness"]
73+
if "enabled" not in formatted_variation and flag_status is not None:
74+
formatted_variation["enabled"] = flag_status
7075
return formatted_variation
7176

72-
def get_variant(self, context: dict) -> dict:
77+
def get_variant(self, context: dict, flag_status: Optional[bool] = None) -> dict:
7378
"""
7479
Determines what variation a user is in.
7580
7681
:param context:
82+
:param flag_status:
7783
:return:
7884
"""
7985
fallback_variant = copy.deepcopy(DISABLED_VARIATION)
8086

8187
if self.variants:
8288
override_variant = self._apply_overrides(context)
8389
if override_variant:
84-
return self._format_variation(override_variant)
90+
return self._format_variation(override_variant, flag_status)
8591

8692
total_weight = sum(x["weight"] for x in self.variants)
8793
if total_weight <= 0:
@@ -92,17 +98,18 @@ def get_variant(self, context: dict) -> dict:
9298
if "stickiness" in self.variants[0].keys()
9399
else "default"
94100
)
101+
95102
target = utils.normalized_hash(
96103
self._get_seed(context, stickiness_selector),
97-
self.feature_name,
104+
self.group_id,
98105
total_weight,
99106
)
100107
counter = 0
101108
for variation in self.variants:
102109
counter += variation["weight"]
103110

104111
if counter >= target:
105-
return self._format_variation(variation)
112+
return self._format_variation(variation, flag_status)
106113

107114
# Catch all return.
108115
return fallback_variant

0 commit comments

Comments
 (0)