diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index be2be2c5..1be050c1 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -762,6 +762,16 @@ def get_decision_for_flag( experiment = project_config.get_experiment_from_id(experiment_id) if experiment: + # Skip experiments with unsupported types. + # If the experiment type is None (not set in datafile), we still evaluate it. + # If the experiment type is set but not in the supported list, we skip it. + if experiment.type is not None and experiment.type not in entities.ExperimentTypes.SUPPORTED_TYPES: + self.logger.debug( + f'Skipping experiment "{experiment.key}" with unsupported type ' + f'"{experiment.type}" for feature "{feature_flag.key}".' + ) + continue + # Check for forced decision optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext( feature_flag.key, experiment.key) diff --git a/optimizely/entities.py b/optimizely/entities.py index 12f4f849..411bdeff 100644 --- a/optimizely/entities.py +++ b/optimizely/entities.py @@ -11,7 +11,7 @@ # See the License for the specific language governing permissions and # limitations under the License. from __future__ import annotations -from typing import TYPE_CHECKING, Any, Optional, Sequence +from typing import TYPE_CHECKING, Any, Final, Optional, Sequence from sys import version_info if version_info < (3, 8): @@ -72,6 +72,20 @@ def __init__(self, id: str, key: str, experimentIds: list[str], **kwargs: Any): self.experimentIds = experimentIds +class ExperimentTypes: + """Supported experiment types recognized by the SDK. + + Experiments with a type not in SUPPORTED_TYPES will be skipped during flag decisions. + If an experiment has no type (None), it is still evaluated. + """ + AB = 'a/b' + MAB = 'mab' + CMAB = 'cmab' + FEATURE_ROLLOUTS = 'feature_rollouts' + + SUPPORTED_TYPES: Final = frozenset({AB, MAB, CMAB, FEATURE_ROLLOUTS}) + + class Experiment(BaseEntity): def __init__( self, @@ -87,6 +101,7 @@ def __init__( groupId: Optional[str] = None, groupPolicy: Optional[str] = None, cmab: Optional[CmabDict] = None, + type: Optional[str] = None, **kwargs: Any ): self.id = id @@ -101,6 +116,7 @@ def __init__( self.groupId = groupId self.groupPolicy = groupPolicy self.cmab = cmab + self.type = type def get_audience_conditions_or_ids(self) -> Sequence[str | list[str]]: """ Returns audienceConditions if present, otherwise audienceIds. """ diff --git a/tests/test_decision_service.py b/tests/test_decision_service.py index b38a03b2..fd9e8626 100644 --- a/tests/test_decision_service.py +++ b/tests/test_decision_service.py @@ -2008,3 +2008,102 @@ def test_get_variation_for_feature_returns_rollout_in_experiment_bucket_range_25 mock_config_logging.debug.assert_called_with( 'Assigned bucket 4000 to user with bucketing ID "test_user".') mock_generate_bucket_value.assert_called_with("test_user211147") + + def test_get_decision_for_flag_skips_unsupported_experiment_type(self): + """Test that experiments with unsupported types are skipped during flag decisions.""" + + user = optimizely_user_context.OptimizelyUserContext( + optimizely_client=None, logger=None, user_id="test_user", user_attributes={} + ) + feature = self.project_config.get_feature_from_key("test_feature_in_experiment") + + # Get the experiment and set an unsupported type + experiment = self.project_config.get_experiment_from_key("test_experiment") + original_type = experiment.type + experiment.type = "unsupported_type" + + try: + with mock.patch( + "optimizely.decision_service.DecisionService.get_variation" + ) as mock_get_variation: + result = self.decision_service.get_variation_for_feature( + self.project_config, feature, user, options=None + ) + # get_variation should NOT have been called since the experiment type is unsupported + mock_get_variation.assert_not_called() + finally: + experiment.type = original_type + + def test_get_decision_for_flag_evaluates_experiment_with_none_type(self): + """Test that experiments with None type (not set in datafile) are still evaluated.""" + + user = optimizely_user_context.OptimizelyUserContext( + optimizely_client=None, logger=None, user_id="test_user", user_attributes={} + ) + feature = self.project_config.get_feature_from_key("test_feature_in_experiment") + + # Make sure the experiment type is None + experiment = self.project_config.get_experiment_from_key("test_experiment") + original_type = experiment.type + experiment.type = None + + expected_variation = self.project_config.get_variation_from_id("test_experiment", "111129") + try: + with mock.patch( + "optimizely.decision_service.DecisionService.get_variation", + return_value={'variation': expected_variation, 'cmab_uuid': None, 'reasons': [], 'error': False}, + ) as mock_get_variation: + result = self.decision_service.get_variation_for_feature( + self.project_config, feature, user, options=None + ) + # get_variation SHOULD be called since the experiment type is None + mock_get_variation.assert_called_once() + finally: + experiment.type = original_type + + def test_get_decision_for_flag_evaluates_supported_experiment_types(self): + """Test that experiments with supported types are evaluated.""" + + user = optimizely_user_context.OptimizelyUserContext( + optimizely_client=None, logger=None, user_id="test_user", user_attributes={} + ) + feature = self.project_config.get_feature_from_key("test_feature_in_experiment") + experiment = self.project_config.get_experiment_from_key("test_experiment") + expected_variation = self.project_config.get_variation_from_id("test_experiment", "111129") + original_type = experiment.type + + for exp_type in entities.ExperimentTypes.SUPPORTED_TYPES: + experiment.type = exp_type + with mock.patch( + "optimizely.decision_service.DecisionService.get_variation", + return_value={'variation': expected_variation, 'cmab_uuid': None, 'reasons': [], 'error': False}, + ) as mock_get_variation: + result = self.decision_service.get_variation_for_feature( + self.project_config, feature, user, options=None + ) + mock_get_variation.assert_called_once() + + experiment.type = original_type + + def test_experiment_type_field_parsed_from_datafile(self): + """Test that the type field is correctly parsed when constructing Experiment entities.""" + # Test with type set + exp_with_type = entities.Experiment( + id="123", key="test", status="Running", audienceIds=[], + variations=[], forcedVariations={}, trafficAllocation=[], + layerId="1", type="a/b" + ) + self.assertEqual("a/b", exp_with_type.type) + + # Test with type not set (default is None) + exp_without_type = entities.Experiment( + id="456", key="test2", status="Running", audienceIds=[], + variations=[], forcedVariations={}, trafficAllocation=[], + layerId="1" + ) + self.assertIsNone(exp_without_type.type) + + def test_supported_experiment_types_values(self): + """Test that the supported experiment types contain the expected values.""" + expected_types = {'a/b', 'mab', 'cmab', 'feature_rollouts'} + self.assertEqual(expected_types, entities.ExperimentTypes.SUPPORTED_TYPES)