diff --git a/providers/openfeature-provider-flagd/openfeature/test-harness b/providers/openfeature-provider-flagd/openfeature/test-harness index 3bff4b7e..dc43f1cb 160000 --- a/providers/openfeature-provider-flagd/openfeature/test-harness +++ b/providers/openfeature-provider-flagd/openfeature/test-harness @@ -1 +1 @@ -Subproject commit 3bff4b7eaee0efc8cfe60e0ef6fbd77441b370e6 +Subproject commit dc43f1cbb714968054a79c3b99658927c9a813ae diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/custom_ops.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/custom_ops.py index b44cc75b..2b55d4d7 100644 --- a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/custom_ops.py +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/custom_ops.py @@ -6,6 +6,8 @@ import mmh3 import semver +MAX_WEIGHT_SUM = 2_147_483_647 # MaxInt32 + JsonPrimitive: typing.TypeAlias = str | bool | float | int JsonLogicArg: typing.TypeAlias = JsonPrimitive | Sequence[JsonPrimitive] @@ -14,50 +16,56 @@ @dataclass class Fraction: - variant: str + variant: str | float | int | bool | None weight: int = 1 -def fractional(data: dict, *args: JsonLogicArg) -> str | None: +def _resolve_bucket_by(data: dict, args: tuple) -> tuple[str | None, tuple]: + if isinstance(args[0], str): + return args[0], args[1:] + + seed = data.get("$flagd", {}).get("flagKey", "") + targeting_key = data.get("targetingKey") + if not targeting_key: + logger.error("No targetingKey provided for fractional shorthand syntax.") + return None, args + return seed + targeting_key, args + + +def fractional(data: dict, *args: JsonLogicArg) -> str | float | int | bool | None: if not args: logger.error("No arguments provided to fractional operator.") return None - bucket_by = None - if isinstance(args[0], str): - bucket_by = args[0] - args = args[1:] - else: - seed = data.get("$flagd", {}).get("flagKey", "") - targeting_key = data.get("targetingKey") - if not targeting_key: - logger.error("No targetingKey provided for fractional shorthand syntax.") - return None - bucket_by = seed + targeting_key + bucket_by, args = _resolve_bucket_by(data, args) if not bucket_by: logger.error("No hashKey value resolved") return None - hash_ratio = abs(mmh3.hash(bucket_by)) / (2**31 - 1) - bucket = hash_ratio * 100 + hash_value = mmh3.hash(bucket_by, signed=False) total_weight = 0 fractions = [] try: for arg in args: fraction = _parse_fraction(arg) - if fraction: - fractions.append(fraction) - total_weight += fraction.weight + fractions.append(fraction) + total_weight += fraction.weight except ValueError: logger.debug(f"Invalid {args} configuration") return None - range_end: float = 0 + if total_weight > MAX_WEIGHT_SUM: + logger.error(f"Total fractional weight exceeds MaxInt32 ({MAX_WEIGHT_SUM:,}).") + return None + + bucket = (hash_value * total_weight) >> 32 + + range_end = 0 for fraction in fractions: - range_end += fraction.weight * 100 / total_weight + range_end += fraction.weight if bucket < range_end: return fraction.variant return None @@ -69,19 +77,21 @@ def _parse_fraction(arg: JsonLogicArg) -> Fraction: "Fractional variant weights must be (str, int) tuple or [str] list" ) - if not isinstance(arg[0], str): - raise ValueError( - "Fractional variant identifier (first element) isn't of type 'str'" - ) - - if len(arg) >= 2 and not isinstance(arg[1], int): - raise ValueError( - "Fractional variant weight value (second element) isn't of type 'int'" - ) - - fraction = Fraction(variant=arg[0]) - if len(arg) >= 2: - fraction.weight = arg[1] + variant = arg[0] + + weight = None + if len(arg) == 2: + w = arg[1] + if isinstance(w, bool): + raise ValueError("Fractional weight value isn't of type 'int'") + elif isinstance(w, int): + weight = w + else: + raise ValueError("Fractional weight value isn't of type 'int'") + + fraction = Fraction(variant=variant) + if weight is not None: + fraction.weight = weight return fraction diff --git a/providers/openfeature-provider-flagd/tests/e2e/file/conftest.py b/providers/openfeature-provider-flagd/tests/e2e/file/conftest.py index 08e07377..0ad64b9e 100644 --- a/providers/openfeature-provider-flagd/tests/e2e/file/conftest.py +++ b/providers/openfeature-provider-flagd/tests/e2e/file/conftest.py @@ -14,6 +14,7 @@ "~grace", "~contextEnrichment", "~deprecated", + "~fractional-v1", } diff --git a/providers/openfeature-provider-flagd/tests/e2e/inprocess/conftest.py b/providers/openfeature-provider-flagd/tests/e2e/inprocess/conftest.py index 49811e10..39ba630c 100644 --- a/providers/openfeature-provider-flagd/tests/e2e/inprocess/conftest.py +++ b/providers/openfeature-provider-flagd/tests/e2e/inprocess/conftest.py @@ -4,7 +4,7 @@ from tests.e2e.testfilter import TestFilter resolver = ResolverType.IN_PROCESS -feature_list = ["~targetURI", "~unixsocket", "~deprecated"] +feature_list = ["~targetURI", "~unixsocket", "~deprecated", "~fractional-v1"] def pytest_collection_modifyitems(config, items): diff --git a/providers/openfeature-provider-flagd/tests/e2e/rpc/conftest.py b/providers/openfeature-provider-flagd/tests/e2e/rpc/conftest.py index 8804bb94..04382edd 100644 --- a/providers/openfeature-provider-flagd/tests/e2e/rpc/conftest.py +++ b/providers/openfeature-provider-flagd/tests/e2e/rpc/conftest.py @@ -10,6 +10,9 @@ "~sync", "~metadata", "~deprecated", + "~fractional-v1", + "~fractional-v2", + "~fractional-nested", ] diff --git a/providers/openfeature-provider-flagd/tests/test_targeting.py b/providers/openfeature-provider-flagd/tests/test_targeting.py index deac55fb..b0205f2a 100644 --- a/providers/openfeature-provider-flagd/tests/test_targeting.py +++ b/providers/openfeature-provider-flagd/tests/test_targeting.py @@ -182,7 +182,7 @@ def test_should_evaluate_valid_rule2(self): logic = targeting( "flagA", rule, EvaluationContext(attributes={"key": "bucketKeyB"}) ) - assert logic == "blue" + assert logic == "red" def test_should_evaluate_valid_rule_with_targeting_key(self): rule = { @@ -193,7 +193,7 @@ def test_should_evaluate_valid_rule_with_targeting_key(self): } logic = targeting("flagA", rule, EvaluationContext(targeting_key="bucketKeyB")) - assert logic == "blue" + assert logic == "red" def test_should_evaluate_valid_rule_with_targeting_key_although_one_does_not_have_a_fraction( self, @@ -203,7 +203,7 @@ def test_should_evaluate_valid_rule_with_targeting_key_although_one_does_not_hav } logic = targeting("flagA", rule, EvaluationContext(targeting_key="bucketKeyB")) - assert logic == "blue" + assert logic == "red" def test_should_return_null_if_targeting_key_is_missing(self): rule = { @@ -216,46 +216,256 @@ def test_should_return_null_if_targeting_key_is_missing(self): logic = jsonLogic(rule, {}, OPERATORS) assert logic is None - def test_bucket_sum_with_sum_bigger_than_100(self): + def test_no_args_returns_none(self): + logic = fractional({}) + assert logic is None + + def test_omitted_weight_defaults_to_1(self): + rule = { + "fractional": [["red", 1], ["blue"]], + } + logic = targeting("flagA", rule, EvaluationContext(targeting_key="bucketKeyB")) + assert logic == "red" + + def test_weight_zero_bucket_never_wins(self): rule = { "fractional": [ - ["red", 55], - ["blue", 55], + ["never", 0], + ["always", 1], ], } + logic = targeting("flagA", rule, EvaluationContext(targeting_key="any")) + assert logic == "always" + def test_weight_as_fractional_float_is_invalid(self): + rule = { + "fractional": [ + ["red", 50.0], + ["blue", 50], + ], + } logic = targeting("flagA", rule, EvaluationContext(targeting_key="key")) - assert logic == "blue" + assert logic is None - def test_bucket_sum_with_sum_lower_than_100(self): + def test_weight_as_bool_is_invalid(self): rule = { "fractional": [ - ["red", 45], - ["blue", 45], + ["red", True], + ["blue", 50], ], } + logic = targeting("flagA", rule, EvaluationContext(targeting_key="key")) + assert logic is None + def test_weight_as_string_is_invalid(self): + rule = { + "fractional": [ + ["red", "50"], + ["blue", 50], + ], + } logic = targeting("flagA", rule, EvaluationContext(targeting_key="key")) - assert logic == "blue" + assert logic is None - def test_buckets_properties_to_have_variant_and_fraction(self): + def test_dynamic_weight_from_var_expression(self): + # seed="flagAkey" → bucket=55; rolloutPercent=70 → new-feature=[0,70), control=[70,100) rule = { "fractional": [ - ["red", 50], - [100, 50], + ["new-feature", {"var": "rolloutPercent"}], + ["control", {"-": [100, {"var": "rolloutPercent"}]}], ], } + logic = targeting( + "flagA", + rule, + EvaluationContext(targeting_key="key", attributes={"rolloutPercent": 70}), + ) + assert logic == "new-feature" + + def test_total_weight_exceeds_max_int32_returns_none(self): + logic = targeting( + "flagA", + {"fractional": [["red", 2_147_483_647], ["blue", 1]]}, + EvaluationContext(targeting_key="key"), + ) + assert logic is None + + def test_variant_as_string(self): + rule = {"fractional": [["red", 1]]} + logic = targeting("flagA", rule, EvaluationContext(targeting_key="key")) + assert logic == "red" + def test_variant_as_int(self): + rule = {"fractional": [[42, 1]]} + logic = targeting("flagA", rule, EvaluationContext(targeting_key="key")) + assert logic == 42 + + def test_variant_as_float(self): + rule = {"fractional": [[3.14, 1]]} + logic = targeting("flagA", rule, EvaluationContext(targeting_key="key")) + assert logic == 3.14 + + def test_variant_as_bool_true(self): + rule = {"fractional": [[True, 1]]} + logic = targeting("flagA", rule, EvaluationContext(targeting_key="key")) + assert logic is True + + def test_variant_as_none(self): + rule = {"fractional": [[None, 1]]} logic = targeting("flagA", rule, EvaluationContext(targeting_key="key")) assert logic is None - def test_buckets_properties_to_have_variant_and_fraction2(self): + def test_mixed_variant_types_all_participate(self): + # seed="flagAkey", 4 buckets weight 1 each → bucket=2 → third bucket → variant=1 (int) + rule = { + "fractional": [ + ["clubs", 1], + [True, 1], + [1, 1], + [None, 1], + ], + } + logic = targeting("flagA", rule, EvaluationContext(targeting_key="key")) + assert logic == 1 + + def test_nested_if_as_variant_name(self): + rule = { + "fractional": [ + {"var": "targetingKey"}, + [ + { + "if": [ + {"==": [{"var": "tier"}, "premium"]}, + "premium", + "standard", + ] + }, + 50, + ], + ["standard", 50], + ], + } + assert ( + targeting( + "fractional-nested-if-flag", + rule, + EvaluationContext( + targeting_key="jon@company.com", attributes={"tier": "premium"} + ), + ) + == "premium" + ) + assert ( + targeting( + "fractional-nested-if-flag", + rule, + EvaluationContext( + targeting_key="jon@company.com", attributes={"tier": "basic"} + ), + ) + == "standard" + ) + + def test_nested_var_as_variant_name_resolved(self): + rule = { + "fractional": [ + {"var": "targetingKey"}, + [{"var": "color"}, 50], + ["blue", 50], + ], + } + assert ( + targeting( + "fractional-nested-var-flag", + rule, + EvaluationContext( + targeting_key="jon@company.com", attributes={"color": "red"} + ), + ) + == "red" + ) + + def test_nested_var_as_variant_name_absent_key_resolves_to_none(self): + rule = { + "fractional": [ + {"var": "targetingKey"}, + [{"var": "color"}, 50], + ["blue", 50], + ], + } + logic = targeting( + "fractional-nested-var-flag", + rule, + EvaluationContext(targeting_key="jon@company.com"), + ) + assert logic is None + + def test_nested_fractional_as_variant_name(self): + # json_logic evaluates the inner {"fractional":[...]} before the outer one sees it. + # Inner: seed="flagAkey", bucket=55 → hearts=[50,75) → "hearts". + # Outer: seed="flagAkey", bucket=1, buckets are ["clubs",1]=[0,1) and [inner,1]=[1,2) → inner & "hearts". + inner = { + "fractional": [ + ["clubs", 25], + ["diamonds", 25], + ["hearts", 25], + ["spades", 25], + ] + } + rule = { + "fractional": [ + ["clubs", 1], + [inner, 1], + ], + } + logic = targeting("flagA", rule, EvaluationContext(targeting_key="key")) + assert logic == "hearts" + + def test_nested_if_as_weight(self): + rule = { + "fractional": [ + {"var": "targetingKey"}, + ["red", {"if": [{"==": [{"var": "tier"}, "premium"]}, 100, 0]}], + ["blue", 10], + ], + } + assert ( + targeting( + "fractional-nested-weight-flag", + rule, + EvaluationContext( + targeting_key="jon@company.com", attributes={"tier": "premium"} + ), + ) + == "red" + ) + assert ( + targeting( + "fractional-nested-weight-flag", + rule, + EvaluationContext( + targeting_key="jon@company.com", attributes={"tier": "basic"} + ), + ) + == "blue" + ) + + def test_bucket_too_many_elements_returns_none(self): rule = { "fractional": [ ["red", 45, 1256], ["blue", 4, 455], ], } + logic = targeting("flagA", rule, EvaluationContext(targeting_key="key")) + assert logic is None + def test_bucket_empty_list_returns_none(self): + rule = { + "fractional": [ + [], + ["blue", 1], + ], + } logic = targeting("flagA", rule, EvaluationContext(targeting_key="key")) assert logic is None