Skip to content

Commit 5133699

Browse files
Dependent features (#274)
* update specs * add dependencies to feature * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * delay failing test issue * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * feat: feature dependencies * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * test feature dependencies * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * add more dependency test cases * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * refactor: dependency test from bootstrapping * fix: feature dependencies arguments * fix: don't send metrics for parent features --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent b1d5b70 commit 5133699

File tree

7 files changed

+245
-9
lines changed

7 files changed

+245
-9
lines changed

UnleashClient/__init__.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,9 @@ def is_enabled(
348348
if self.unleash_bootstrapped or self.is_initialized:
349349
try:
350350
feature = self.features[feature_name]
351-
feature_check = feature.is_enabled(context)
351+
feature_check = feature.is_enabled(
352+
context
353+
) and self._dependencies_are_satisfied(feature_name, context)
352354

353355
if feature.only_for_metrics:
354356
return self._get_fallback_value(
@@ -429,6 +431,10 @@ def get_variant(self, feature_name: str, context: Optional[dict] = None) -> dict
429431
if self.unleash_bootstrapped or self.is_initialized:
430432
try:
431433
feature = self.features[feature_name]
434+
435+
if not self._dependencies_are_satisfied(feature_name, context):
436+
return DISABLED_VARIATION
437+
432438
variant_check = feature.get_variant(context)
433439

434440
if self.unleash_event_callback and feature.impression_data:
@@ -484,6 +490,57 @@ def get_variant(self, feature_name: str, context: Optional[dict] = None) -> dict
484490
)
485491
return DISABLED_VARIATION
486492

493+
def _is_dependency_satified(self, dependency: dict, context: dict) -> bool:
494+
"""
495+
Checks a single feature dependency.
496+
"""
497+
498+
dependency_name = dependency["feature"]
499+
500+
dependency_feature = self.features[dependency_name]
501+
502+
if not dependency_feature:
503+
LOGGER.warning("Feature dependency not found. %s", dependency_name)
504+
return False
505+
506+
if dependency_feature.dependencies:
507+
LOGGER.warning(
508+
"Feature dependency cannot have it's own dependencies. %s",
509+
dependency_name,
510+
)
511+
return False
512+
513+
should_be_enabled = dependency.get("enabled", True)
514+
is_enabled = dependency_feature.is_enabled(context, skip_stats=True)
515+
516+
if is_enabled != should_be_enabled:
517+
return False
518+
519+
variants = dependency.get("variants")
520+
if variants:
521+
variant = dependency_feature.get_variant(context, skip_stats=True)
522+
if variant["name"] not in variants:
523+
return False
524+
525+
return True
526+
527+
def _dependencies_are_satisfied(self, feature_name: str, context: dict) -> bool:
528+
"""
529+
If feature dependencies are satisfied (or non-existent).
530+
"""
531+
532+
feature = self.features[feature_name]
533+
dependencies = feature.dependencies
534+
535+
if not dependencies:
536+
return True
537+
538+
for dependency in dependencies:
539+
if not self._is_dependency_satified(dependency, context):
540+
return False
541+
542+
return True
543+
487544
def _do_instance_check(self, multiple_instance_mode):
488545
identifier = self.__get_identifier()
489546
if identifier in INSTANCES:

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.3.1"
9+
CLIENT_SPEC_VERSION = "4.5.0"
1010

1111
# =Unleash=
1212
APPLICATION_HEADERS = {

UnleashClient/features/Feature.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ def __init__(
1717
strategies: list,
1818
variants: Optional[Variants] = None,
1919
impression_data: bool = False,
20+
dependencies: list = None,
2021
) -> None:
2122
"""
2223
A representation of a feature object
@@ -44,6 +45,12 @@ def __init__(
4445
# Whether the feature exists only for tracking metrics or not.
4546
self.only_for_metrics = False
4647

48+
# Prerequisite state of other features that this feature depends on
49+
self.dependencies = [
50+
dict(dependency, enabled=dependency.get("enabled", True))
51+
for dependency in dependencies or []
52+
]
53+
4754
def reset_stats(self) -> None:
4855
"""
4956
Resets stats after metrics reporting
@@ -75,20 +82,20 @@ def _count_variant(self, variant_name: str) -> None:
7582
"""
7683
self.variant_counts[variant_name] = self.variant_counts.get(variant_name, 0) + 1
7784

78-
def is_enabled(self, context: dict = None) -> bool:
85+
def is_enabled(self, context: dict = None, skip_stats: bool = False) -> bool:
7986
"""
8087
Checks if feature is enabled.
8188
8289
:param context: Context information
8390
:return:
8491
"""
85-
evaluation_result = self._get_evaluation_result(context)
92+
evaluation_result = self._get_evaluation_result(context, skip_stats)
8693

8794
flag_value = evaluation_result.enabled
8895

8996
return flag_value
9097

91-
def get_variant(self, context: dict = None) -> dict:
98+
def get_variant(self, context: dict = None, skip_stats: bool = False) -> dict:
9299
"""
93100
Checks if feature is enabled and, if so, get the variant.
94101
@@ -109,11 +116,13 @@ def get_variant(self, context: dict = None) -> dict:
109116

110117
except Exception as variant_exception:
111118
LOGGER.warning("Error selecting variant: %s", variant_exception)
112-
113-
self._count_variant(cast(str, variant["name"]))
119+
if not skip_stats:
120+
self._count_variant(cast(str, variant["name"]))
114121
return variant
115122

116-
def _get_evaluation_result(self, context: dict = None) -> EvaluationResult:
123+
def _get_evaluation_result(
124+
self, context: dict = None, skip_stats: bool = False
125+
) -> EvaluationResult:
117126
strategy_result = EvaluationResult(False, None)
118127
if self.enabled:
119128
try:
@@ -131,7 +140,8 @@ def _get_evaluation_result(self, context: dict = None) -> EvaluationResult:
131140
except Exception as evaluation_except:
132141
LOGGER.warning("Error getting evaluation result: %s", evaluation_except)
133142

134-
self.increment_stats(strategy_result.enabled)
143+
if not skip_stats:
144+
self.increment_stats(strategy_result.enabled)
135145
LOGGER.info("%s evaluation result: %s", self.name, strategy_result)
136146
return strategy_result
137147

UnleashClient/loader.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ def _create_feature(
8686
strategies=parsed_strategies,
8787
variants=variant,
8888
impression_data=provisioning.get("impressionData", False),
89+
dependencies=provisioning.get("dependencies", []),
8990
)
9091

9192

@@ -151,6 +152,10 @@ def load_features(
151152
"impressionData", False
152153
)
153154

155+
feature_for_update.dependencies = parsed_features[feature].get(
156+
"dependencies", []
157+
)
158+
154159
# If the feature had previously been added to the features list only for
155160
# tracking, indicate that it is now a real feature that should be
156161
# evaluated properly.

tests/unit_tests/test_client.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from tests.utilities.mocks.mock_features import (
1414
MOCK_FEATURE_RESPONSE,
1515
MOCK_FEATURE_RESPONSE_PROJECT,
16+
MOCK_FEATURE_WITH_DEPENDENCIES_RESPONSE,
1617
)
1718
from tests.utilities.testing_constants import (
1819
APP_NAME,
@@ -121,6 +122,22 @@ def unleash_client_toggle_only(cache):
121122
unleash_client.destroy()
122123

123124

125+
@pytest.fixture()
126+
def unleash_client_bootstrap_dependencies():
127+
cache = FileCache("MOCK_CACHE")
128+
cache.bootstrap_from_dict(MOCK_FEATURE_WITH_DEPENDENCIES_RESPONSE)
129+
unleash_client = UnleashClient(
130+
url=URL,
131+
app_name=APP_NAME,
132+
disable_metrics=True,
133+
disable_registration=True,
134+
cache=cache,
135+
environment="default",
136+
)
137+
unleash_client.initialize_client(fetch_toggles=False)
138+
yield unleash_client
139+
140+
124141
def test_UC_initialize_default():
125142
client = UnleashClient(URL, APP_NAME)
126143
assert client.unleash_url == URL
@@ -355,6 +372,15 @@ def test_uc_not_initialized_isenabled():
355372
)
356373

357374

375+
def test_uc_dependency(unleash_client_bootstrap_dependencies):
376+
unleash_client = unleash_client_bootstrap_dependencies
377+
assert unleash_client.is_enabled("Child")
378+
assert not unleash_client.is_enabled("WithDisabledDependency")
379+
assert unleash_client.is_enabled("ComplexExample")
380+
assert not unleash_client.is_enabled("UnlistedDependency")
381+
assert not unleash_client.is_enabled("TransitiveDependency")
382+
383+
358384
@responses.activate
359385
def test_uc_get_variant():
360386
# Set up API
@@ -431,6 +457,27 @@ def test_uc_registers_metrics_for_nonexistent_features(unleash_client):
431457
assert request["bucket"]["toggles"]["nonexistent-flag"]["no"] == 1
432458

433459

460+
@responses.activate
461+
def test_uc_metrics_dependencies(unleash_client):
462+
responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202)
463+
responses.add(
464+
responses.GET,
465+
URL + FEATURES_URL,
466+
json=MOCK_FEATURE_WITH_DEPENDENCIES_RESPONSE,
467+
status=200,
468+
)
469+
responses.add(responses.POST, URL + METRICS_URL, json={}, status=202)
470+
471+
unleash_client.initialize_client()
472+
time.sleep(1)
473+
assert unleash_client.is_enabled("Child")
474+
475+
time.sleep(12)
476+
request = json.loads(responses.calls[-1].request.body)
477+
assert request["bucket"]["toggles"]["Child"]["yes"] == 1
478+
assert "Parent" not in request["bucket"]["toggles"]
479+
480+
434481
@responses.activate
435482
def test_uc_registers_variant_metrics_for_nonexistent_features(unleash_client):
436483
# Set up API

tests/unit_tests/test_features.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ def test_feature_strategy_variants():
4646
yield Feature("My Feature", True, strategies, variants)
4747

4848

49+
@pytest.fixture()
50+
def test_feature_dependencies():
51+
strategies = [Default()]
52+
variants = Variants(VARIANTS, "My Feature")
53+
dependencies = [
54+
{"feature": "prerequisite"},
55+
{"feature": "disabledDependency", "enabled": False},
56+
{"feature": "withVariants", "variants": ["VarA", "VarB"]},
57+
]
58+
yield Feature("My Feature", True, strategies, variants, dependencies=dependencies)
59+
60+
4961
def test_create_feature_true(test_feature):
5062
my_feature = test_feature
5163

@@ -140,3 +152,14 @@ def test_strategy_variant_is_returned(test_feature_strategy_variants):
140152
"name": "VarB",
141153
"payload": {"type": "string", "value": "Test 2"},
142154
}
155+
156+
157+
def test_dependencies(test_feature_dependencies):
158+
assert isinstance(test_feature_dependencies.dependencies, list)
159+
assert all(
160+
isinstance(item, dict) for item in test_feature_dependencies.dependencies
161+
)
162+
assert all("feature" in item for item in test_feature_dependencies.dependencies)
163+
assert all("enabled" in item for item in test_feature_dependencies.dependencies)
164+
# if no enabled key is provided, it should default to True
165+
assert test_feature_dependencies.dependencies[0]["enabled"]

tests/utilities/mocks/mock_features.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,97 @@
158158
}
159159
],
160160
}
161+
162+
MOCK_FEATURE_WITH_DEPENDENCIES_RESPONSE = {
163+
"version": 1,
164+
"features": [
165+
{
166+
"name": "Parent",
167+
"description": "Dependency on Child feature toggle",
168+
"enabled": True,
169+
"strategies": [
170+
{
171+
"name": "default",
172+
"parameters": {},
173+
"variants": [
174+
{
175+
"name": "variant1",
176+
"weight": 1000,
177+
"stickiness": "default",
178+
"weightType": "variable",
179+
}
180+
],
181+
}
182+
],
183+
"createdAt": "2018-10-09T06:04:05.667Z",
184+
"impressionData": False,
185+
},
186+
{
187+
"name": "Child",
188+
"description": "Feature toggle that depends on Parent feature toggle",
189+
"enabled": True,
190+
"strategies": [{"name": "default", "parameters": {}}],
191+
"createdAt": "2018-10-09T06:04:05.667Z",
192+
"impressionData": False,
193+
"dependencies": [
194+
{
195+
"feature": "Parent",
196+
}
197+
],
198+
},
199+
{
200+
"name": "Disabled",
201+
"description": "Disabled feature toggle",
202+
"enabled": False,
203+
"strategies": [{"name": "default", "parameters": {}}],
204+
"createdAt": "2023-10-06T11:53:02.161Z",
205+
"impressionData": False,
206+
},
207+
{
208+
"name": "WithDisabledDependency",
209+
"description": "Feature toggle that depends on Parent feature toggle",
210+
"enabled": True,
211+
"strategies": [{"name": "default", "parameters": {}}],
212+
"createdAt": "2023-10-06T12:04:05.667Z",
213+
"impressionData": False,
214+
"dependencies": [
215+
{
216+
"feature": "Disabled",
217+
}
218+
],
219+
},
220+
{
221+
"name": "ComplexExample",
222+
"description": "Feature toggle that depends on multiple feature toggles",
223+
"enabled": True,
224+
"strategies": [{"name": "default", "parameters": {}}],
225+
"createdAt": "2023-10-06T12:04:05.667Z",
226+
"impressionData": False,
227+
"dependencies": [
228+
{"feature": "Parent", "variants": ["variant1"]},
229+
{
230+
"feature": "Disabled",
231+
"enabled": False,
232+
},
233+
],
234+
},
235+
{
236+
"name": "UnlistedDependency",
237+
"description": "Feature toggle that depends on a feature toggle that does not exist",
238+
"enabled": True,
239+
"strategies": [{"name": "default", "parameters": {}}],
240+
"createdAt": "2023-10-06T12:04:05.667Z",
241+
"impressionData": False,
242+
"dependencies": [{"feature": "DoesNotExist"}],
243+
},
244+
{
245+
"name": "TransitiveDependency",
246+
"description": "Feature toggle that depends on a feature toggle that has a dependency",
247+
"enabled": True,
248+
"strategies": [{"name": "default", "parameters": {}}],
249+
"createdAt": "2023-10-06T12:04:05.667Z",
250+
"impressionData": False,
251+
"dependencies": [{"feature": "Child"}],
252+
},
253+
],
254+
}

0 commit comments

Comments
 (0)