Skip to content

Commit 05cfc85

Browse files
authored
Last One Wins with Duplicate Feature Flag Names (#71)
* Last One Wins * Update _featuremanagerbase.py * Cleanup plus missing test * Update _featuremanagerbase.py * fixing tests
1 parent 83fc1ad commit 05cfc85

4 files changed

Lines changed: 103 additions & 47 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,3 +413,4 @@ docs/doctrees
413413
docs/html
414414
package-lock.json
415415
package.json
416+
.env

featuremanagement/_featuremanagerbase.py

Lines changed: 40 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import hashlib
99
import logging
1010
from abc import ABC
11-
from typing import List, Optional, Dict, Tuple, Any, Mapping, Callable
11+
from typing import List, Optional, Dict, Tuple, Any, Mapping, Callable, cast
1212
from ._models import FeatureFlag, Variant, VariantAssignmentReason, TargetingContext, EvaluationEvent, VariantReference
1313

1414
FEATURE_MANAGEMENT_KEY = "feature_management"
@@ -25,50 +25,6 @@
2525
logger = logging.getLogger(__name__)
2626

2727

28-
def _get_feature_flag(configuration: Mapping[str, Any], feature_flag_name: str) -> Optional[FeatureFlag]:
29-
"""
30-
Gets the FeatureFlag json from the configuration, if it exists it gets converted to a FeatureFlag object.
31-
32-
:param Mapping configuration: Configuration object.
33-
:param str feature_flag_name: Name of the feature flag.
34-
:return: FeatureFlag
35-
:rtype: FeatureFlag
36-
"""
37-
feature_management = configuration.get(FEATURE_MANAGEMENT_KEY)
38-
if not feature_management or not isinstance(feature_management, Mapping):
39-
return None
40-
feature_flags = feature_management.get(FEATURE_FLAG_KEY)
41-
if not feature_flags or not isinstance(feature_flags, list):
42-
return None
43-
44-
for feature_flag in feature_flags:
45-
if feature_flag.get("id") == feature_flag_name:
46-
return FeatureFlag.convert_from_json(feature_flag)
47-
48-
return None
49-
50-
51-
def _list_feature_flag_names(configuration: Mapping[str, Any]) -> List[str]:
52-
"""
53-
List of all feature flag names.
54-
55-
:param Mapping configuration: Configuration object.
56-
:return: List of feature flag names.
57-
"""
58-
feature_flag_names = []
59-
feature_management = configuration.get(FEATURE_MANAGEMENT_KEY)
60-
if not feature_management or not isinstance(feature_management, Mapping):
61-
return []
62-
feature_flags = feature_management.get(FEATURE_FLAG_KEY)
63-
if not feature_flags or not isinstance(feature_flags, list):
64-
return []
65-
66-
for feature_flag in feature_flags:
67-
feature_flag_names.append(feature_flag.get("id"))
68-
69-
return feature_flag_names
70-
71-
7228
class FeatureManagerBase(ABC):
7329
"""
7430
Base class for Feature Manager. This class is responsible for all shared logic between the sync and async.
@@ -273,7 +229,7 @@ def _check_feature_base(self, feature_flag_id: str) -> Tuple[EvaluationEvent, bo
273229
self._copy = self._configuration.get(FEATURE_MANAGEMENT_KEY)
274230

275231
if not self._cache.get(feature_flag_id):
276-
feature_flag = _get_feature_flag(self._configuration, feature_flag_id)
232+
feature_flag = self._get_feature_flag(feature_flag_id)
277233
self._cache[feature_flag_id] = feature_flag
278234
else:
279235
feature_flag = self._cache.get(feature_flag_id)
@@ -300,4 +256,41 @@ def list_feature_flag_names(self) -> List[str]:
300256
"""
301257
List of all feature flag names.
302258
"""
303-
return _list_feature_flag_names(self._configuration)
259+
feature_flag_names: Dict[str, None] = {}
260+
for feature_flag in self._get_feature_flags():
261+
feature_flag_name = feature_flag.get("id")
262+
# Only include entries with a valid string id; duplicates are listed once.
263+
if isinstance(feature_flag_name, str):
264+
feature_flag_names[feature_flag_name] = None
265+
266+
return list(feature_flag_names)
267+
268+
def _get_feature_flag(self, feature_flag_name: str) -> Optional[FeatureFlag]:
269+
"""
270+
Gets the FeatureFlag json from the configuration, if it exists it gets converted to a FeatureFlag object.
271+
272+
:param str feature_flag_name: Name of the feature flag.
273+
:return: FeatureFlag
274+
:rtype: FeatureFlag
275+
"""
276+
for feature_flag in reversed(self._get_feature_flags()):
277+
# If multiple feature flags share the same id, the last one defined wins.
278+
if feature_flag.get("id") == feature_flag_name:
279+
return FeatureFlag.convert_from_json(feature_flag)
280+
281+
return None
282+
283+
def _get_feature_flags(self) -> List[Any]:
284+
"""
285+
Gets the list of raw feature flag definitions from the configuration.
286+
287+
:return: List of feature flag definitions, or an empty list if none are configured.
288+
:rtype: list
289+
"""
290+
feature_management = self._configuration.get(FEATURE_MANAGEMENT_KEY)
291+
if not feature_management or not isinstance(feature_management, Mapping):
292+
return []
293+
feature_flags = feature_management.get(FEATURE_FLAG_KEY)
294+
if not feature_flags or not isinstance(feature_flags, list):
295+
return []
296+
return cast(List[Any], feature_flags)

tests/test_feature_manager.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,36 @@ def test_list_feature_flags(self):
112112
assert not feature_manager.is_enabled("Beta")
113113
assert len(feature_manager.list_feature_flag_names()) == 2
114114

115+
# method: list_feature_flags
116+
def test_list_feature_flags_no_feature_flags(self):
117+
# feature_management is present but has no valid feature_flags list.
118+
feature_manager = FeatureManager({"feature_management": {"feature_flags": None}})
119+
assert feature_manager is not None
120+
assert not feature_manager.list_feature_flag_names()
121+
assert not feature_manager.is_enabled("Alpha")
122+
123+
# feature_flags is present but is not a list.
124+
feature_manager = FeatureManager({"feature_management": {"feature_flags": "not-a-list"}})
125+
assert not feature_manager.list_feature_flag_names()
126+
assert not feature_manager.is_enabled("Alpha")
127+
128+
# method: is_enabled
129+
def test_duplicate_feature_flag_last_wins(self):
130+
feature_flags = {
131+
"feature_management": {
132+
"feature_flags": [
133+
{"id": "Alpha", "description": "", "enabled": "true", "conditions": {"client_filters": []}},
134+
{"id": "Alpha", "description": "", "enabled": "false", "conditions": {"client_filters": []}},
135+
]
136+
}
137+
}
138+
feature_manager = FeatureManager(feature_flags)
139+
assert feature_manager is not None
140+
# The last feature flag with the same id should win.
141+
assert not feature_manager.is_enabled("Alpha")
142+
# Duplicate ids should only be listed once.
143+
assert feature_manager.list_feature_flag_names() == ["Alpha"]
144+
115145
# method: is_enabled
116146
def test_unknown_feature_filter(self):
117147
feature_flags = {

tests/test_feature_manager_async.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,38 @@ async def test_list_feature_flags(self):
119119
assert not await feature_manager.is_enabled("Beta")
120120
assert len(feature_manager.list_feature_flag_names()) == 2
121121

122+
# method: list_feature_flags
123+
@pytest.mark.asyncio
124+
async def test_list_feature_flags_no_feature_flags(self):
125+
# feature_management is present but has no valid feature_flags list.
126+
feature_manager = FeatureManager({"feature_management": {"feature_flags": None}})
127+
assert feature_manager is not None
128+
assert not feature_manager.list_feature_flag_names()
129+
assert not await feature_manager.is_enabled("Alpha")
130+
131+
# feature_flags is present but is not a list.
132+
feature_manager = FeatureManager({"feature_management": {"feature_flags": "not-a-list"}})
133+
assert not feature_manager.list_feature_flag_names()
134+
assert not await feature_manager.is_enabled("Alpha")
135+
136+
# method: is_enabled
137+
@pytest.mark.asyncio
138+
async def test_duplicate_feature_flag_last_wins(self):
139+
feature_flags = {
140+
"feature_management": {
141+
"feature_flags": [
142+
{"id": "Alpha", "description": "", "enabled": "true", "conditions": {"client_filters": []}},
143+
{"id": "Alpha", "description": "", "enabled": "false", "conditions": {"client_filters": []}},
144+
]
145+
}
146+
}
147+
feature_manager = FeatureManager(feature_flags)
148+
assert feature_manager is not None
149+
# The last feature flag with the same id should win.
150+
assert not await feature_manager.is_enabled("Alpha")
151+
# Duplicate ids should only be listed once.
152+
assert feature_manager.list_feature_flag_names() == ["Alpha"]
153+
122154
# method: is_enabled
123155
@pytest.mark.asyncio
124156
async def test_unknown_feature_filter(self):

0 commit comments

Comments
 (0)