From 71fe1481062230aeac3e529b99978d96b944b3d1 Mon Sep 17 00:00:00 2001 From: Hexecu Date: Sun, 30 Aug 2026 07:49:43 +0200 Subject: [PATCH 1/3] fix: reject boolean values for integer flags Exclude bool from integer flag type checks while preserving other integer subclasses. Return the caller default when the async client detects a type mismatch, matching the sync path. Cover sync and async value/details getters, exact fallback types, hook behavior, and valid flag values with regression tests for #619. Signed-off-by: Hexecu --- openfeature/client.py | 7 ++- tests/test_client.py | 117 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/openfeature/client.py b/openfeature/client.py index 8adedf9b..a54f1836 100644 --- a/openfeature/client.py +++ b/openfeature/client.py @@ -890,7 +890,7 @@ async def _create_provider_evaluation_async( if err := _typecheck_flag_value(value=resolution.value, flag_type=flag_type): return FlagEvaluationDetails( flag_key=flag_key, - value=resolution.value, + value=default_value, reason=Reason.ERROR, error_code=err.error_code, error_message=err.error_message, @@ -1001,6 +1001,9 @@ def _typecheck_flag_value( py_type = type_map.get(flag_type) if not py_type: return GeneralError(error_message="Unknown flag type") - if not isinstance(value, py_type): + # bool is an int subclass in Python, but not an integer flag value. + if not isinstance(value, py_type) or ( + flag_type == FlagType.INTEGER and isinstance(value, bool) + ): return TypeMismatchError(f"Expected type {py_type} but got {type(value)}") return None diff --git a/tests/test_client.py b/tests/test_client.py index 64598936..97d2e49b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -434,6 +434,123 @@ async def test_typecheck_flag_value_general_error(): assert err.error_message == "Unknown flag type" +@pytest.mark.parametrize( + "flag_type, flag_value, default_value", + [ + ("integer", True, 1), + ("integer", False, 0), + ("float", True, 1.0), + ("float", False, 0.0), + ("boolean", 1, False), + ("integer", "1", 0), + ("float", 1, 0.0), + ("string", True, "fallback"), + ("object", True, {}), + ], +) +@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"]) +@pytest.mark.asyncio +async def test_client_returns_default_on_type_mismatch( + flag_type, flag_value, default_value, is_async +): + provider = InMemoryProvider( + {"flag": InMemoryFlag("enabled", {"enabled": flag_value})} + ) + api.set_provider_and_wait(provider) + client = get_client() + spy_hook = MagicMock(spec=Hook) + client.add_hooks([spy_hook]) + suffix = "_async" if is_async else "" + + get_details = getattr(client, f"get_{flag_type}_details{suffix}") + details = get_details("flag", default_value) + if is_async: + details = await details + + assert details.flag_key == "flag" + assert details.value == default_value + # Equality alone cannot distinguish True from 1 (or False from 0). + assert type(details.value) is type(default_value) + assert details.reason == Reason.ERROR + assert details.error_code == ErrorCode.TYPE_MISMATCH + expected_type = (dict, list) if flag_type == "object" else type(default_value) + assert ( + details.error_message + == f"Expected type {expected_type} but got {type(flag_value)}" + ) + spy_hook.error.assert_called_once() + assert ( + spy_hook.error.call_args.kwargs["exception"].error_code + == ErrorCode.TYPE_MISMATCH + ) + spy_hook.after.assert_not_called() + spy_hook.finally_after.assert_called_once() + assert spy_hook.finally_after.call_args.kwargs["details"].value == default_value + assert type(spy_hook.finally_after.call_args.kwargs["details"].value) is type( + default_value + ) + + get_value = getattr(client, f"get_{flag_type}_value{suffix}") + value = get_value("flag", default_value) + if is_async: + value = await value + assert value == default_value + assert type(value) is type(default_value) + + +@pytest.mark.parametrize( + "flag_type, flag_value, default_value", + [ + ("boolean", True, False), + ("boolean", False, True), + ("integer", 0, -1), + ("integer", 1, -1), + ("integer", -1, 0), + ("float", 0.0, -1.0), + ("float", 1.5, -1.0), + ("string", "enabled", "default"), + ("object", {"enabled": True}, {}), + ("object", [True, 1, "enabled"], []), + ], +) +@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"]) +@pytest.mark.asyncio +async def test_client_preserves_matching_flag_types( + flag_type, flag_value, default_value, is_async +): + provider = InMemoryProvider( + {"flag": InMemoryFlag("enabled", {"enabled": flag_value})} + ) + api.set_provider_and_wait(provider) + client = get_client() + suffix = "_async" if is_async else "" + + get_details = getattr(client, f"get_{flag_type}_details{suffix}") + details = get_details("flag", default_value) + if is_async: + details = await details + + assert details.value == flag_value + assert type(details.value) is type(flag_value) + assert details.variant == "enabled" + assert details.reason == Reason.STATIC + assert details.error_code is None + + get_value = getattr(client, f"get_{flag_type}_value{suffix}") + value = get_value("flag", default_value) + if is_async: + value = await value + assert value == flag_value + assert type(value) is type(flag_value) + + +def test_typecheck_flag_value_accepts_integer_subclasses(): + class IntegerValue(int): + pass + + assert _typecheck_flag_value(IntegerValue(1), FlagType.INTEGER) is None + + @pytest.mark.asyncio async def test_typecheck_flag_value_type_mismatch_error(): # Given From 1e2e89dc969dee19a684ceae5b9398d03f0b0958 Mon Sep 17 00:00:00 2001 From: Hexecu Date: Sun, 30 Aug 2026 08:32:42 +0200 Subject: [PATCH 2/3] docs: explain flag type validation and fallback behavior Document the boolean/integer distinction, async type-mismatch fallback, and the regression cases. Keep the evaluation logic and test assertions unchanged. Signed-off-by: Hexecu --- openfeature/client.py | 25 ++++++++++++++++++++++++- tests/test_client.py | 8 ++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/openfeature/client.py b/openfeature/client.py index a54f1836..17638aa1 100644 --- a/openfeature/client.py +++ b/openfeature/client.py @@ -861,6 +861,19 @@ async def _create_provider_evaluation_async( default_value: FlagValueType, evaluation_context: EvaluationContext | None = None, ) -> FlagEvaluationDetails[FlagValueType]: + """ + Resolve a flag asynchronously and validate the returned value. + + Provider-reported errors are returned unchanged. A type mismatch detected + by the client returns the caller's default with TYPE_MISMATCH error details. + + :param provider: the provider selected for this evaluation + :param flag_type: the requested flag type + :param flag_key: the key of the selected flag + :param default_value: fallback used for an unknown type or a type mismatch + :param evaluation_context: context passed to the provider + :return: evaluation details containing the resolved value or fallback + """ get_details_callables_async: Mapping[FlagType, ResolveDetailsCallableAsync] = { FlagType.BOOLEAN: provider.resolve_boolean_details_async, FlagType.INTEGER: provider.resolve_integer_details_async, @@ -991,6 +1004,17 @@ def track( def _typecheck_flag_value( value: typing.Any, flag_type: FlagType ) -> OpenFeatureError | None: + """ + Check a resolved value against the requested flag type without coercing it. + + Booleans are not integer flag values, even though bool subclasses int in + Python. Other subclasses of the expected type remain valid. + + :param value: the value returned by the provider + :param flag_type: the requested flag type + :return: None for a matching value, TypeMismatchError for an incompatible + value, or GeneralError for an unknown flag type + """ type_map: TypeMap = { FlagType.BOOLEAN: bool, FlagType.STRING: str, @@ -1001,7 +1025,6 @@ def _typecheck_flag_value( py_type = type_map.get(flag_type) if not py_type: return GeneralError(error_message="Unknown flag type") - # bool is an int subclass in Python, but not an integer flag value. if not isinstance(value, py_type) or ( flag_type == FlagType.INTEGER and isinstance(value, bool) ): diff --git a/tests/test_client.py b/tests/test_client.py index 97d2e49b..228bed94 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -424,6 +424,7 @@ async def test_client_type_mismatch_exceptions(): @pytest.mark.asyncio async def test_typecheck_flag_value_general_error(): + """Unknown flag types produce GENERAL rather than TYPE_MISMATCH.""" # Given flag_value = "A" flag_type = None @@ -453,6 +454,7 @@ async def test_typecheck_flag_value_general_error(): async def test_client_returns_default_on_type_mismatch( flag_type, flag_value, default_value, is_async ): + """Both APIs return typed defaults and pass the fallback to finally hooks.""" provider = InMemoryProvider( {"flag": InMemoryFlag("enabled", {"enabled": flag_value})} ) @@ -518,6 +520,7 @@ async def test_client_returns_default_on_type_mismatch( async def test_client_preserves_matching_flag_types( flag_type, flag_value, default_value, is_async ): + """Matching values keep their type and successful evaluation details.""" provider = InMemoryProvider( {"flag": InMemoryFlag("enabled", {"enabled": flag_value})} ) @@ -545,7 +548,11 @@ async def test_client_preserves_matching_flag_types( def test_typecheck_flag_value_accepts_integer_subclasses(): + """Rejecting booleans must not reject other integer subclasses.""" + class IntegerValue(int): + """An integer subtype with the same value semantics as int.""" + pass assert _typecheck_flag_value(IntegerValue(1), FlagType.INTEGER) is None @@ -553,6 +560,7 @@ class IntegerValue(int): @pytest.mark.asyncio async def test_typecheck_flag_value_type_mismatch_error(): + """An incompatible value reports both the expected and actual types.""" # Given flag_value = "A" flag_type = FlagType.BOOLEAN From 4e16c60ff8f453b7ba94a3dde38b873bbe67d0c3 Mon Sep 17 00:00:00 2001 From: Hexecu Date: Sun, 30 Aug 2026 16:10:23 +0200 Subject: [PATCH 3/3] docs: address review feedback on type checks Signed-off-by: Hexecu --- openfeature/client.py | 25 +------------------------ tests/test_client.py | 8 -------- 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/openfeature/client.py b/openfeature/client.py index 17638aa1..84ae35ac 100644 --- a/openfeature/client.py +++ b/openfeature/client.py @@ -861,19 +861,6 @@ async def _create_provider_evaluation_async( default_value: FlagValueType, evaluation_context: EvaluationContext | None = None, ) -> FlagEvaluationDetails[FlagValueType]: - """ - Resolve a flag asynchronously and validate the returned value. - - Provider-reported errors are returned unchanged. A type mismatch detected - by the client returns the caller's default with TYPE_MISMATCH error details. - - :param provider: the provider selected for this evaluation - :param flag_type: the requested flag type - :param flag_key: the key of the selected flag - :param default_value: fallback used for an unknown type or a type mismatch - :param evaluation_context: context passed to the provider - :return: evaluation details containing the resolved value or fallback - """ get_details_callables_async: Mapping[FlagType, ResolveDetailsCallableAsync] = { FlagType.BOOLEAN: provider.resolve_boolean_details_async, FlagType.INTEGER: provider.resolve_integer_details_async, @@ -1004,17 +991,6 @@ def track( def _typecheck_flag_value( value: typing.Any, flag_type: FlagType ) -> OpenFeatureError | None: - """ - Check a resolved value against the requested flag type without coercing it. - - Booleans are not integer flag values, even though bool subclasses int in - Python. Other subclasses of the expected type remain valid. - - :param value: the value returned by the provider - :param flag_type: the requested flag type - :return: None for a matching value, TypeMismatchError for an incompatible - value, or GeneralError for an unknown flag type - """ type_map: TypeMap = { FlagType.BOOLEAN: bool, FlagType.STRING: str, @@ -1025,6 +1001,7 @@ def _typecheck_flag_value( py_type = type_map.get(flag_type) if not py_type: return GeneralError(error_message="Unknown flag type") + # bool subclasses int, but booleans are not integer flag values. if not isinstance(value, py_type) or ( flag_type == FlagType.INTEGER and isinstance(value, bool) ): diff --git a/tests/test_client.py b/tests/test_client.py index 228bed94..97d2e49b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -424,7 +424,6 @@ async def test_client_type_mismatch_exceptions(): @pytest.mark.asyncio async def test_typecheck_flag_value_general_error(): - """Unknown flag types produce GENERAL rather than TYPE_MISMATCH.""" # Given flag_value = "A" flag_type = None @@ -454,7 +453,6 @@ async def test_typecheck_flag_value_general_error(): async def test_client_returns_default_on_type_mismatch( flag_type, flag_value, default_value, is_async ): - """Both APIs return typed defaults and pass the fallback to finally hooks.""" provider = InMemoryProvider( {"flag": InMemoryFlag("enabled", {"enabled": flag_value})} ) @@ -520,7 +518,6 @@ async def test_client_returns_default_on_type_mismatch( async def test_client_preserves_matching_flag_types( flag_type, flag_value, default_value, is_async ): - """Matching values keep their type and successful evaluation details.""" provider = InMemoryProvider( {"flag": InMemoryFlag("enabled", {"enabled": flag_value})} ) @@ -548,11 +545,7 @@ async def test_client_preserves_matching_flag_types( def test_typecheck_flag_value_accepts_integer_subclasses(): - """Rejecting booleans must not reject other integer subclasses.""" - class IntegerValue(int): - """An integer subtype with the same value semantics as int.""" - pass assert _typecheck_flag_value(IntegerValue(1), FlagType.INTEGER) is None @@ -560,7 +553,6 @@ class IntegerValue(int): @pytest.mark.asyncio async def test_typecheck_flag_value_type_mismatch_error(): - """An incompatible value reports both the expected and actual types.""" # Given flag_value = "A" flag_type = FlagType.BOOLEAN