From 98850b866f6b552227022886e3e3bc670f2ae7ce Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 25 Sep 2026 08:12:58 +0200 Subject: [PATCH 1/2] fix: support hexadecimal OpenFeature numeric variants --- .sampo/changesets/cranky-duchess-ilmatar.md | 5 ++++ .../contrib/provider/posthog/provider.py | 10 +++++-- .../tests/test_provider_unit.py | 29 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 .sampo/changesets/cranky-duchess-ilmatar.md diff --git a/.sampo/changesets/cranky-duchess-ilmatar.md b/.sampo/changesets/cranky-duchess-ilmatar.md new file mode 100644 index 000000000..70d19e8e6 --- /dev/null +++ b/.sampo/changesets/cranky-duchess-ilmatar.md @@ -0,0 +1,5 @@ +--- +pypi/openfeature-provider-posthog: patch +--- + +Support hexadecimal numeric variants in the OpenFeature provider. diff --git a/openfeature-provider/openfeature/contrib/provider/posthog/provider.py b/openfeature-provider/openfeature/contrib/provider/posthog/provider.py index d8e59690a..bd3a3de7b 100644 --- a/openfeature-provider/openfeature/contrib/provider/posthog/provider.py +++ b/openfeature-provider/openfeature/contrib/provider/posthog/provider.py @@ -8,6 +8,7 @@ from __future__ import annotations import logging +import re from typing import Any, Callable, Mapping, Optional, Sequence, TypeVar, Union from openfeature.evaluation_context import EvaluationContext @@ -53,7 +54,9 @@ class PostHogProvider(AbstractProvider): Flag-type mapping (all via ``get_feature_flag_result``): * boolean -> ``enabled`` * string -> the multivariate ``variant`` key - * int/float -> the ``variant`` parsed to a number + * int/float -> the ``variant`` parsed to a number, including unsigned + hexadecimal integers such as ``0x10`` (returned as int or float, + respectively) * object -> the flag's JSON ``payload`` Args: @@ -213,7 +216,10 @@ def _resolve_number( f"Flag '{flag_key}' has no variant to parse as {ctor.__name__}." ) try: - value = ctor(result.variant) + variant = result.variant + if re.fullmatch(r"0[xX][0-9a-fA-F]+", variant.strip()): + variant = str(int(variant, 16)) + value = ctor(variant) except (TypeError, ValueError) as exc: raise TypeMismatchError( f"Flag '{flag_key}' variant '{result.variant}' is not a valid " diff --git a/openfeature-provider/tests/test_provider_unit.py b/openfeature-provider/tests/test_provider_unit.py index cefd30529..e33ce83b8 100644 --- a/openfeature-provider/tests/test_provider_unit.py +++ b/openfeature-provider/tests/test_provider_unit.py @@ -100,6 +100,35 @@ def test_number_variant_parse_failure(fake_client, resolver, variant): getattr(_provider(fake_client), resolver)("n", 0, EvaluationContext("u")) +@pytest.mark.parametrize( + "resolver", ["resolve_integer_details", "resolve_float_details"] +) +@pytest.mark.parametrize( + ("variant", "expected"), + [("0x10", 16), ("0Xff", 255), (" 0xA0 ", 160), ("0x0", 0), ("042", 42)], +) +def test_hex_number_variant(fake_client, resolver, variant, expected): + fake_client.get_feature_flag_result.return_value = make_result(variant=variant) + details = getattr(_provider(fake_client), resolver)("n", 0, EvaluationContext("u")) + assert details.value == expected + assert type(details.value) is ( + int if resolver == "resolve_integer_details" else float + ) + assert details.variant == variant + + +@pytest.mark.parametrize( + "resolver", ["resolve_integer_details", "resolve_float_details"] +) +@pytest.mark.parametrize( + "variant", ["0x", "0xgg", "0x1.5", "0x_10", "0x1_0", "-0x10", "+0x10"] +) +def test_invalid_hex_number_variant(fake_client, resolver, variant): + fake_client.get_feature_flag_result.return_value = make_result(variant=variant) + with pytest.raises(TypeMismatchError): + getattr(_provider(fake_client), resolver)("n", 0, EvaluationContext("u")) + + def test_object_payload(fake_client): fake_client.get_feature_flag_result.return_value = make_result( enabled=True, variant="v1", payload={"color": "blue"} From 52832defaaaa66ee66cfeb8bdca68282e393349c Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 25 Sep 2026 08:44:37 +0200 Subject: [PATCH 2/2] fix: preserve numeric variants and parse large hex integers directly --- .../contrib/provider/posthog/provider.py | 12 ++++--- .../tests/test_provider_unit.py | 32 +++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/openfeature-provider/openfeature/contrib/provider/posthog/provider.py b/openfeature-provider/openfeature/contrib/provider/posthog/provider.py index bd3a3de7b..006c7f699 100644 --- a/openfeature-provider/openfeature/contrib/provider/posthog/provider.py +++ b/openfeature-provider/openfeature/contrib/provider/posthog/provider.py @@ -205,7 +205,7 @@ def _resolve_number( flag_key: str, default_value: _N, evaluation_context: Optional[EvaluationContext], - ctor: Callable[[str], _N], + ctor: Callable[[Union[str, int]], _N], ) -> FlagResolutionDetails[_N]: result = self._resolve(flag_key, evaluation_context) if result.variant is None: @@ -216,11 +216,13 @@ def _resolve_number( f"Flag '{flag_key}' has no variant to parse as {ctor.__name__}." ) try: - variant = result.variant - if re.fullmatch(r"0[xX][0-9a-fA-F]+", variant.strip()): - variant = str(int(variant, 16)) + variant: Union[str, int] = result.variant + if isinstance(variant, str) and re.fullmatch( + r"0[xX][0-9a-fA-F]+", variant.strip() + ): + variant = int(variant, 16) value = ctor(variant) - except (TypeError, ValueError) as exc: + except (TypeError, ValueError, OverflowError) as exc: raise TypeMismatchError( f"Flag '{flag_key}' variant '{result.variant}' is not a valid " f"{ctor.__name__}." diff --git a/openfeature-provider/tests/test_provider_unit.py b/openfeature-provider/tests/test_provider_unit.py index e33ce83b8..effdf72a9 100644 --- a/openfeature-provider/tests/test_provider_unit.py +++ b/openfeature-provider/tests/test_provider_unit.py @@ -73,6 +73,10 @@ def test_string_on_boolean_flag_is_type_mismatch(fake_client): ("resolve_integer_details", "3", 3), ("resolve_float_details", "3.5", 3.5), ("resolve_float_details", "3", 3.0), + ("resolve_integer_details", 42, 42), + ("resolve_integer_details", 3.5, 3), + ("resolve_float_details", 42, 42.0), + ("resolve_float_details", 3.5, 3.5), ], ) def test_number_variant_parse(fake_client, resolver, variant, expected): @@ -129,6 +133,34 @@ def test_invalid_hex_number_variant(fake_client, resolver, variant): getattr(_provider(fake_client), resolver)("n", 0, EvaluationContext("u")) +def test_hex_integer_beyond_decimal_string_limit(fake_client): + variant = "0x" + "f" * 4000 + fake_client.get_feature_flag_result.return_value = make_result(variant=variant) + details = _provider(fake_client).resolve_integer_details( + "n", 0, EvaluationContext("u") + ) + assert details.value == (1 << 16000) - 1 + assert details.variant == variant + + +def test_large_representable_hex_float(fake_client): + variant = "0x" + "f" * 255 + fake_client.get_feature_flag_result.return_value = make_result(variant=variant) + details = _provider(fake_client).resolve_float_details( + "n", 0.0, EvaluationContext("u") + ) + assert details.value == float(int(variant, 16)) + assert isinstance(details.value, float) + + +def test_hex_float_overflow_is_type_mismatch(fake_client): + fake_client.get_feature_flag_result.return_value = make_result( + variant="0x" + "f" * 4000 + ) + with pytest.raises(TypeMismatchError): + _provider(fake_client).resolve_float_details("n", 0.0, EvaluationContext("u")) + + def test_object_payload(fake_client): fake_client.get_feature_flag_result.return_value = make_result( enabled=True, variant="v1", payload={"color": "blue"}