Skip to content

Commit 9b4af2f

Browse files
[FSSDK-11569][FSSDK-11570] Python: Holdout Project Config update (#464)
* [FSSDK-11569][FSSDK-11570] Python: Holdout Project Config update * Fix lint issues * Fix the python issue * Fix the python issue on typing.txt
1 parent 116d23a commit 9b4af2f

File tree

4 files changed

+253
-1
lines changed

4 files changed

+253
-1
lines changed

optimizely/project_config.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,38 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any):
8888
region_value = config.get('region')
8989
self.region: str = region_value or 'US'
9090

91+
self.holdouts: list[dict[str, Any]] = config.get('holdouts', [])
92+
self.holdout_id_map: dict[str, dict[str, Any]] = {}
93+
self.global_holdouts: dict[str, dict[str, Any]] = {}
94+
self.included_holdouts: dict[str, list[dict[str, Any]]] = {}
95+
self.excluded_holdouts: dict[str, list[dict[str, Any]]] = {}
96+
self.flag_holdouts_map: dict[str, list[dict[str, Any]]] = {}
97+
98+
for holdout in self.holdouts:
99+
if holdout.get('status') != 'Running':
100+
continue
101+
102+
holdout_id = holdout['id']
103+
self.holdout_id_map[holdout_id] = holdout
104+
105+
included_flags = holdout.get('includedFlags')
106+
if not included_flags:
107+
# This is a global holdout
108+
self.global_holdouts[holdout_id] = holdout
109+
110+
excluded_flags = holdout.get('excludedFlags')
111+
if excluded_flags:
112+
for flag_id in excluded_flags:
113+
if flag_id not in self.excluded_holdouts:
114+
self.excluded_holdouts[flag_id] = []
115+
self.excluded_holdouts[flag_id].append(holdout)
116+
else:
117+
# This holdout applies to specific flags
118+
for flag_id in included_flags:
119+
if flag_id not in self.included_holdouts:
120+
self.included_holdouts[flag_id] = []
121+
self.included_holdouts[flag_id].append(holdout)
122+
91123
# Utility maps for quick lookup
92124
self.group_id_map: dict[str, entities.Group] = self._generate_key_map(self.groups, 'id', entities.Group)
93125
self.experiment_id_map: dict[str, entities.Experiment] = self._generate_key_map(
@@ -752,3 +784,62 @@ def get_flag_variation(
752784
return variation
753785

754786
return None
787+
788+
def get_holdouts_for_flag(self, flag_key: str) -> list[Any]:
789+
""" Helper method to get holdouts from an applied feature flag.
790+
791+
Args:
792+
flag_key: Key of the feature flag.
793+
794+
Returns:
795+
The holdouts that apply for a specific flag.
796+
"""
797+
feature_flag = self.feature_key_map.get(flag_key)
798+
if not feature_flag:
799+
return []
800+
801+
flag_id = feature_flag.id
802+
803+
# Check cache first
804+
if flag_id in self.flag_holdouts_map:
805+
return self.flag_holdouts_map[flag_id]
806+
807+
holdouts = []
808+
809+
# Add global holdouts that don't exclude this flag
810+
for holdout in self.global_holdouts.values():
811+
is_excluded = False
812+
excluded_flags = holdout.get('excludedFlags')
813+
if excluded_flags:
814+
for excluded_flag_id in excluded_flags:
815+
if excluded_flag_id == flag_id:
816+
is_excluded = True
817+
break
818+
if not is_excluded:
819+
holdouts.append(holdout)
820+
821+
# Add holdouts that specifically include this flag
822+
if flag_id in self.included_holdouts:
823+
holdouts.extend(self.included_holdouts[flag_id])
824+
825+
# Cache the result
826+
self.flag_holdouts_map[flag_id] = holdouts
827+
828+
return holdouts
829+
830+
def get_holdout(self, holdout_id: str) -> Optional[dict[str, Any]]:
831+
""" Helper method to get holdout from holdout ID.
832+
833+
Args:
834+
holdout_id: ID of the holdout.
835+
836+
Returns:
837+
The holdout corresponding to the provided holdout ID.
838+
"""
839+
holdout = self.holdout_id_map.get(holdout_id)
840+
841+
if holdout:
842+
return holdout
843+
844+
self.logger.error(f'Holdout with ID "{holdout_id}" not found.')
845+
return None

requirements/core.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ jsonschema>=3.2.0
22
pyrsistent>=0.16.0
33
requests>=2.21
44
idna>=2.10
5+
rpds-py<0.20.0; python_version < '3.11'

requirements/typing.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
mypy
22
types-jsonschema
33
types-requests
4-
types-Flask
4+
types-Flask
5+
rpds-py<0.20.0; python_version < '3.11'

tests/test_config.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1375,3 +1375,162 @@ def test_get_variation_from_key_by_experiment_id_missing(self):
13751375
variation = project_config.get_variation_from_key_by_experiment_id(experiment_id, variation_key)
13761376

13771377
self.assertIsNone(variation)
1378+
1379+
1380+
class HoldoutConfigTest(base.BaseTest):
1381+
def setUp(self):
1382+
base.BaseTest.setUp(self)
1383+
1384+
# Create config with holdouts
1385+
config_body_with_holdouts = self.config_dict_with_features.copy()
1386+
1387+
# Use correct feature flag IDs from the datafile
1388+
boolean_feature_id = '91111' # boolean_single_variable_feature
1389+
multi_variate_feature_id = '91114' # test_feature_in_experiment_and_rollout
1390+
1391+
config_body_with_holdouts['holdouts'] = [
1392+
{
1393+
'id': 'holdout_1',
1394+
'key': 'global_holdout',
1395+
'status': 'Running',
1396+
'includedFlags': [],
1397+
'excludedFlags': [boolean_feature_id]
1398+
},
1399+
{
1400+
'id': 'holdout_2',
1401+
'key': 'specific_holdout',
1402+
'status': 'Running',
1403+
'includedFlags': [multi_variate_feature_id],
1404+
'excludedFlags': []
1405+
},
1406+
{
1407+
'id': 'holdout_3',
1408+
'key': 'inactive_holdout',
1409+
'status': 'Inactive',
1410+
'includedFlags': [boolean_feature_id],
1411+
'excludedFlags': []
1412+
}
1413+
]
1414+
1415+
self.config_json_with_holdouts = json.dumps(config_body_with_holdouts)
1416+
opt_obj = optimizely.Optimizely(self.config_json_with_holdouts)
1417+
self.config_with_holdouts = opt_obj.config_manager.get_config()
1418+
1419+
def test_get_holdouts_for_flag__non_existent_flag(self):
1420+
""" Test that get_holdouts_for_flag returns empty array for non-existent flag. """
1421+
1422+
holdouts = self.config_with_holdouts.get_holdouts_for_flag('non_existent_flag')
1423+
self.assertEqual([], holdouts)
1424+
1425+
def test_get_holdouts_for_flag__returns_global_and_specific_holdouts(self):
1426+
""" Test that get_holdouts_for_flag returns global holdouts that do not exclude the flag
1427+
and specific holdouts that include the flag. """
1428+
1429+
holdouts = self.config_with_holdouts.get_holdouts_for_flag('test_feature_in_experiment_and_rollout')
1430+
self.assertEqual(2, len(holdouts))
1431+
1432+
global_holdout = next((h for h in holdouts if h['key'] == 'global_holdout'), None)
1433+
self.assertIsNotNone(global_holdout)
1434+
self.assertEqual('holdout_1', global_holdout['id'])
1435+
1436+
specific_holdout = next((h for h in holdouts if h['key'] == 'specific_holdout'), None)
1437+
self.assertIsNotNone(specific_holdout)
1438+
self.assertEqual('holdout_2', specific_holdout['id'])
1439+
1440+
def test_get_holdouts_for_flag__excludes_global_holdouts_for_excluded_flags(self):
1441+
""" Test that get_holdouts_for_flag does not return global holdouts that exclude the flag. """
1442+
1443+
holdouts = self.config_with_holdouts.get_holdouts_for_flag('boolean_single_variable_feature')
1444+
self.assertEqual(0, len(holdouts))
1445+
1446+
global_holdout = next((h for h in holdouts if h['key'] == 'global_holdout'), None)
1447+
self.assertIsNone(global_holdout)
1448+
1449+
def test_get_holdouts_for_flag__caches_results(self):
1450+
""" Test that get_holdouts_for_flag caches results for subsequent calls. """
1451+
1452+
holdouts1 = self.config_with_holdouts.get_holdouts_for_flag('test_feature_in_experiment_and_rollout')
1453+
holdouts2 = self.config_with_holdouts.get_holdouts_for_flag('test_feature_in_experiment_and_rollout')
1454+
1455+
# Should be the same object (cached)
1456+
self.assertIs(holdouts1, holdouts2)
1457+
self.assertEqual(2, len(holdouts1))
1458+
1459+
def test_get_holdouts_for_flag__returns_only_global_for_non_targeted_flags(self):
1460+
""" Test that get_holdouts_for_flag returns only global holdouts for flags not specifically targeted. """
1461+
1462+
holdouts = self.config_with_holdouts.get_holdouts_for_flag('test_feature_in_rollout')
1463+
1464+
# Should only include global holdout (not excluded and no specific targeting)
1465+
self.assertEqual(1, len(holdouts))
1466+
self.assertEqual('global_holdout', holdouts[0]['key'])
1467+
1468+
def test_get_holdout__returns_holdout_for_valid_id(self):
1469+
""" Test that get_holdout returns holdout when valid ID is provided. """
1470+
1471+
holdout = self.config_with_holdouts.get_holdout('holdout_1')
1472+
self.assertIsNotNone(holdout)
1473+
self.assertEqual('holdout_1', holdout['id'])
1474+
self.assertEqual('global_holdout', holdout['key'])
1475+
self.assertEqual('Running', holdout['status'])
1476+
1477+
def test_get_holdout__returns_holdout_regardless_of_status(self):
1478+
""" Test that get_holdout returns holdout regardless of status when valid ID is provided. """
1479+
1480+
holdout = self.config_with_holdouts.get_holdout('holdout_2')
1481+
self.assertIsNotNone(holdout)
1482+
self.assertEqual('holdout_2', holdout['id'])
1483+
self.assertEqual('specific_holdout', holdout['key'])
1484+
self.assertEqual('Running', holdout['status'])
1485+
1486+
def test_get_holdout__returns_none_for_non_existent_id(self):
1487+
""" Test that get_holdout returns None for non-existent holdout ID. """
1488+
1489+
holdout = self.config_with_holdouts.get_holdout('non_existent_holdout')
1490+
self.assertIsNone(holdout)
1491+
1492+
def test_get_holdout__logs_error_when_not_found(self):
1493+
""" Test that get_holdout logs error when holdout is not found. """
1494+
1495+
with mock.patch.object(self.config_with_holdouts, 'logger') as mock_logger:
1496+
result = self.config_with_holdouts.get_holdout('invalid_holdout_id')
1497+
1498+
self.assertIsNone(result)
1499+
mock_logger.error.assert_called_once_with('Holdout with ID "invalid_holdout_id" not found.')
1500+
1501+
def test_get_holdout__does_not_log_when_found(self):
1502+
""" Test that get_holdout does not log when holdout is found. """
1503+
1504+
with mock.patch.object(self.config_with_holdouts, 'logger') as mock_logger:
1505+
result = self.config_with_holdouts.get_holdout('holdout_1')
1506+
1507+
self.assertIsNotNone(result)
1508+
mock_logger.error.assert_not_called()
1509+
1510+
def test_holdout_initialization__categorizes_holdouts_properly(self):
1511+
""" Test that holdouts are properly categorized during initialization. """
1512+
1513+
self.assertIn('holdout_1', self.config_with_holdouts.holdout_id_map)
1514+
self.assertIn('holdout_2', self.config_with_holdouts.holdout_id_map)
1515+
self.assertIn('holdout_1', self.config_with_holdouts.global_holdouts)
1516+
1517+
# Use correct feature flag IDs
1518+
boolean_feature_id = '91111'
1519+
multi_variate_feature_id = '91114'
1520+
1521+
self.assertIn(multi_variate_feature_id, self.config_with_holdouts.included_holdouts)
1522+
self.assertTrue(len(self.config_with_holdouts.included_holdouts[multi_variate_feature_id]) > 0)
1523+
self.assertNotIn(boolean_feature_id, self.config_with_holdouts.included_holdouts)
1524+
1525+
self.assertIn(boolean_feature_id, self.config_with_holdouts.excluded_holdouts)
1526+
self.assertTrue(len(self.config_with_holdouts.excluded_holdouts[boolean_feature_id]) > 0)
1527+
1528+
def test_holdout_initialization__only_processes_running_holdouts(self):
1529+
""" Test that only running holdouts are processed during initialization. """
1530+
1531+
self.assertNotIn('holdout_3', self.config_with_holdouts.holdout_id_map)
1532+
self.assertNotIn('holdout_3', self.config_with_holdouts.global_holdouts)
1533+
1534+
boolean_feature_id = '91111'
1535+
included_for_boolean = self.config_with_holdouts.included_holdouts.get(boolean_feature_id)
1536+
self.assertIsNone(included_for_boolean)

0 commit comments

Comments
 (0)