From 0fbbaa43bdcdca42c9ae38f5d6f0eff2fc693978 Mon Sep 17 00:00:00 2001 From: chenak-a Date: Fri, 4 Jul 2025 00:28:13 -0400 Subject: [PATCH 1/3] fix: gracefully cast for numeric flag evaluations --- ld_openfeature/provider.py | 42 +++++++++++++++----------- tests/test_provider.py | 61 +++++++++++++++++++++----------------- 2 files changed, 59 insertions(+), 44 deletions(-) diff --git a/ld_openfeature/provider.py b/ld_openfeature/provider.py index 8bd642c..38ce11a 100644 --- a/ld_openfeature/provider.py +++ b/ld_openfeature/provider.py @@ -1,6 +1,7 @@ import threading from typing import Any, List, Optional, Union +from ldclient.evaluation import EvaluationDetail from ldclient import LDClient, Config from ldclient.interfaces import DataSourceStatus, FlagChange, DataSourceState from openfeature.evaluation_context import EvaluationContext @@ -137,6 +138,20 @@ def resolve_object_details( ) -> FlagResolutionDetails[Union[dict, list]]: """Resolves the flag value for the provided flag key as a list or dictionary""" return self.__resolve_value(FlagType(FlagType.OBJECT), flag_key, default_value, evaluation_context) + + def serialize_value(self, flag_type: FlagType, value: Any): + """Serializes the raw flag value to the expected type based on flag_type.""" + if flag_type == FlagType.BOOLEAN and isinstance(value, bool): + return value + elif flag_type == FlagType.STRING and isinstance(value, str): + return value + elif flag_type == FlagType.INTEGER and isinstance(value, (int, float)) and not isinstance(value, bool): + return int(value) + elif flag_type == FlagType.FLOAT and isinstance(value, (int, float)) and not isinstance(value, bool): + return float(value) + elif flag_type == FlagType.OBJECT and isinstance(value, (dict, list)): + return value + return None def __resolve_value(self, flag_type: FlagType, flag_key: str, default_value: Any, evaluation_context: Optional[EvaluationContext] = None) -> FlagResolutionDetails: @@ -150,24 +165,17 @@ def __resolve_value(self, flag_type: FlagType, flag_key: str, default_value: Any ld_context = self.__context_converter.to_ld_context(evaluation_context) result = self.__client.variation_detail(flag_key, ld_context, default_value) - if flag_type == FlagType.BOOLEAN and not isinstance(result.value, bool): + resolved_value = self.serialize_value(flag_type, result.value) + if resolved_value is None: return self.__mismatched_type_details(default_value) - elif flag_type == FlagType.STRING and not isinstance(result.value, str): - return self.__mismatched_type_details(default_value) - elif flag_type == FlagType.INTEGER and isinstance(result.value, bool): - # Python treats boolean values as instances of int - return self.__mismatched_type_details(default_value) - elif flag_type == FlagType.FLOAT and isinstance(result.value, bool): - # Python treats boolean values as instances of int - return self.__mismatched_type_details(default_value) - elif flag_type == FlagType.INTEGER and not isinstance(result.value, int): - return self.__mismatched_type_details(default_value) - elif flag_type == FlagType.FLOAT and not isinstance(result.value, float) and not isinstance(result.value, int): - return self.__mismatched_type_details(default_value) - elif flag_type == FlagType.OBJECT and not isinstance(result.value, dict) and not isinstance(result.value, list): - return self.__mismatched_type_details(default_value) - - return self.__details_converter.to_resolution_details(result) + + resolved_detail = EvaluationDetail( + value=resolved_value, + variation_index=result.variation_index, + reason=result.reason, + ) + + return self.__details_converter.to_resolution_details(resolved_detail) @staticmethod def __mismatched_type_details(default_value: Any) -> FlagResolutionDetails: diff --git a/tests/test_provider.py b/tests/test_provider.py index edc8893..af581b4 100644 --- a/tests/test_provider.py +++ b/tests/test_provider.py @@ -88,32 +88,37 @@ def test_invalid_types_generate_type_mismatch_results(provider: LaunchDarklyProv @pytest.mark.parametrize( - "default_value,return_value,expected_value,method_name", - [ - pytest.param(True, False, False, 'resolve_boolean_details'), - pytest.param(False, True, True, 'resolve_boolean_details'), - pytest.param(False, 1, False, 'resolve_boolean_details'), - pytest.param(False, "True", False, 'resolve_boolean_details'), - pytest.param(True, [], True, 'resolve_boolean_details'), - - pytest.param('default-string', 'return-string', 'return-string', 'resolve_string_details'), - pytest.param('default-string', 1, 'default-string', 'resolve_string_details'), - pytest.param('default-string', True, 'default-string', 'resolve_string_details'), - - pytest.param(1, 2, 2, 'resolve_integer_details'), - pytest.param(1, True, 1, 'resolve_integer_details'), - pytest.param(1, False, 1, 'resolve_integer_details'), - pytest.param(1, "", 1, 'resolve_integer_details'), - - pytest.param(1.0, 2.0, 2.0, 'resolve_float_details'), - pytest.param(1.0, 2, 2.0, 'resolve_float_details'), - pytest.param(1.0, True, 1.0, 'resolve_float_details'), - pytest.param(1.0, 'return-string', 1.0, 'resolve_float_details'), - - pytest.param(['default-value'], ['return-string'], ['return-string'], 'resolve_object_details'), - pytest.param(['default-value'], True, ['default-value'], 'resolve_object_details'), - pytest.param(['default-value'], 1, ['default-value'], 'resolve_object_details'), - pytest.param(['default-value'], 'return-string', ['default-value'], 'resolve_object_details'), + "default_value,return_value,expected_value,expected_type,method_name", + [ + pytest.param(True, False, False, bool, 'resolve_boolean_details'), + pytest.param(False, True, True, bool, 'resolve_boolean_details'), + pytest.param(False, 1, False, bool, 'resolve_boolean_details'), + pytest.param(False, "True", False, bool, 'resolve_boolean_details'), + pytest.param(True, [], True, bool, 'resolve_boolean_details'), + + pytest.param('default-string', 'return-string', 'return-string', str, 'resolve_string_details'), + pytest.param('default-string', 1, 'default-string', str, 'resolve_string_details'), + pytest.param('default-string', True, 'default-string', str, 'resolve_string_details'), + + pytest.param(1, 2, 2, int, 'resolve_integer_details'), + pytest.param(1, True, 1, int, 'resolve_integer_details'), + pytest.param(1, False, 1, int, 'resolve_integer_details'), + pytest.param(1, "", 1, int, 'resolve_integer_details'), + + pytest.param(1.0, 2.0, 2.0, float, 'resolve_float_details'), + pytest.param(1.0, 2, 2.0, float, 'resolve_float_details'), + pytest.param(1.0, True, 1.0, float, 'resolve_float_details'), + pytest.param(1.0, 'return-string', 1.0, float, 'resolve_float_details'), + + pytest.param(['default-value'], ['return-string'], ['return-string'], list, 'resolve_object_details'), + pytest.param(['default-value'], True, ['default-value'], list, 'resolve_object_details'), + pytest.param(['default-value'], 1, ['default-value'], list, 'resolve_object_details'), + pytest.param(['default-value'], 'return-string', ['default-value'], list, 'resolve_object_details'), + + pytest.param({'key': 'default'}, {'key': 'return'}, {'key': 'return'}, dict, 'resolve_object_details'), + pytest.param({'key': 'default'}, True, {'key': 'default'}, dict, 'resolve_object_details'), + pytest.param({'key': 'default'}, 1, {'key': 'default'}, dict, 'resolve_object_details'), + pytest.param({'key': 'default'}, 'return-string', {'key': 'default'}, dict, 'resolve_object_details'), ], ) def test_check_method_and_result_match_type( @@ -121,6 +126,7 @@ def test_check_method_and_result_match_type( default_value: Union[bool, str, int, float, List], return_value: Union[bool, str, int, float, List], expected_value: Union[bool, str, int, float, List], + expected_type: type, method_name: str, # end of parameterized values test_data_source: TestData, @@ -130,7 +136,8 @@ def test_check_method_and_result_match_type( method = getattr(provider, method_name) resolution_details = method("check-method-flag", default_value, evaluation_context) - + + assert isinstance(resolution_details.value, expected_type) assert resolution_details.value == expected_value From cfe2d3ed36cf3174f30dbd5ae5b118ba83a02d45 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Mon, 7 Jul 2025 20:38:21 +0000 Subject: [PATCH 2/3] adjust method name, visibility, and tests for casting --- ld_openfeature/provider.py | 30 +++++++++++++++--------------- tests/test_provider.py | 3 +++ 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/ld_openfeature/provider.py b/ld_openfeature/provider.py index 38ce11a..28f5e4f 100644 --- a/ld_openfeature/provider.py +++ b/ld_openfeature/provider.py @@ -138,20 +138,6 @@ def resolve_object_details( ) -> FlagResolutionDetails[Union[dict, list]]: """Resolves the flag value for the provided flag key as a list or dictionary""" return self.__resolve_value(FlagType(FlagType.OBJECT), flag_key, default_value, evaluation_context) - - def serialize_value(self, flag_type: FlagType, value: Any): - """Serializes the raw flag value to the expected type based on flag_type.""" - if flag_type == FlagType.BOOLEAN and isinstance(value, bool): - return value - elif flag_type == FlagType.STRING and isinstance(value, str): - return value - elif flag_type == FlagType.INTEGER and isinstance(value, (int, float)) and not isinstance(value, bool): - return int(value) - elif flag_type == FlagType.FLOAT and isinstance(value, (int, float)) and not isinstance(value, bool): - return float(value) - elif flag_type == FlagType.OBJECT and isinstance(value, (dict, list)): - return value - return None def __resolve_value(self, flag_type: FlagType, flag_key: str, default_value: Any, evaluation_context: Optional[EvaluationContext] = None) -> FlagResolutionDetails: @@ -165,7 +151,7 @@ def __resolve_value(self, flag_type: FlagType, flag_key: str, default_value: Any ld_context = self.__context_converter.to_ld_context(evaluation_context) result = self.__client.variation_detail(flag_key, ld_context, default_value) - resolved_value = self.serialize_value(flag_type, result.value) + resolved_value = self.__validate_and_cast_value(flag_type, result.value) if resolved_value is None: return self.__mismatched_type_details(default_value) @@ -176,6 +162,20 @@ def __resolve_value(self, flag_type: FlagType, flag_key: str, default_value: Any ) return self.__details_converter.to_resolution_details(resolved_detail) + + def __validate_and_cast_value(self, flag_type: FlagType, value: Any): + """Serializes the raw flag value to the expected type based on flag_type.""" + if flag_type == FlagType.BOOLEAN and isinstance(value, bool): + return value + elif flag_type == FlagType.STRING and isinstance(value, str): + return value + elif flag_type == FlagType.INTEGER and isinstance(value, (int, float)) and not isinstance(value, bool): + return int(value) # Float decimals are truncated to int + elif flag_type == FlagType.FLOAT and isinstance(value, (int, float)) and not isinstance(value, bool): + return float(value) + elif flag_type == FlagType.OBJECT and isinstance(value, (dict, list)): + return value + return None @staticmethod def __mismatched_type_details(default_value: Any) -> FlagResolutionDetails: diff --git a/tests/test_provider.py b/tests/test_provider.py index af581b4..26ec3eb 100644 --- a/tests/test_provider.py +++ b/tests/test_provider.py @@ -104,11 +104,14 @@ def test_invalid_types_generate_type_mismatch_results(provider: LaunchDarklyProv pytest.param(1, True, 1, int, 'resolve_integer_details'), pytest.param(1, False, 1, int, 'resolve_integer_details'), pytest.param(1, "", 1, int, 'resolve_integer_details'), + pytest.param(1, 2.0, 2, int, 'resolve_integer_details'), + pytest.param(1, 2.9, 2, int, 'resolve_integer_details'), pytest.param(1.0, 2.0, 2.0, float, 'resolve_float_details'), pytest.param(1.0, 2, 2.0, float, 'resolve_float_details'), pytest.param(1.0, True, 1.0, float, 'resolve_float_details'), pytest.param(1.0, 'return-string', 1.0, float, 'resolve_float_details'), + pytest.param(1.0, 2, 2.0, float, 'resolve_float_details'), pytest.param(['default-value'], ['return-string'], ['return-string'], list, 'resolve_object_details'), pytest.param(['default-value'], True, ['default-value'], list, 'resolve_object_details'), From c50df81bc791db0221c0b9ee3e9b9bd229ab13d0 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Mon, 7 Jul 2025 20:48:36 +0000 Subject: [PATCH 3/3] remove unnecessary tests --- tests/test_provider.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_provider.py b/tests/test_provider.py index 26ec3eb..d4fd3e5 100644 --- a/tests/test_provider.py +++ b/tests/test_provider.py @@ -104,14 +104,12 @@ def test_invalid_types_generate_type_mismatch_results(provider: LaunchDarklyProv pytest.param(1, True, 1, int, 'resolve_integer_details'), pytest.param(1, False, 1, int, 'resolve_integer_details'), pytest.param(1, "", 1, int, 'resolve_integer_details'), - pytest.param(1, 2.0, 2, int, 'resolve_integer_details'), pytest.param(1, 2.9, 2, int, 'resolve_integer_details'), pytest.param(1.0, 2.0, 2.0, float, 'resolve_float_details'), pytest.param(1.0, 2, 2.0, float, 'resolve_float_details'), pytest.param(1.0, True, 1.0, float, 'resolve_float_details'), pytest.param(1.0, 'return-string', 1.0, float, 'resolve_float_details'), - pytest.param(1.0, 2, 2.0, float, 'resolve_float_details'), pytest.param(['default-value'], ['return-string'], ['return-string'], list, 'resolve_object_details'), pytest.param(['default-value'], True, ['default-value'], list, 'resolve_object_details'), @@ -140,8 +138,8 @@ def test_check_method_and_result_match_type( method = getattr(provider, method_name) resolution_details = method("check-method-flag", default_value, evaluation_context) - assert isinstance(resolution_details.value, expected_type) assert resolution_details.value == expected_value + #assert isinstance(resolution_details.value, expected_type) def test_logger_changes_should_cascade_to_evaluation_converter(provider: LaunchDarklyProvider, caplog):