Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ class CacheType(Enum):
DEFAULT_PORT_RPC = 8013
DEFAULT_RESOLVER_TYPE = ResolverType.RPC
DEFAULT_RETRY_BACKOFF = 1000
DEFAULT_RETRY_BACKOFF_MAX = 12000
DEFAULT_RETRY_GRACE_PERIOD_SECONDS = 5
DEFAULT_RETRY_BACKOFF_MAX = 5000
DEFAULT_RETRY_GRACE_PERIOD_SECONDS = 10

DEFAULT_STREAM_DEADLINE = 600000
DEFAULT_TLS = False
DEFAULT_TLS_CERT: str | None = None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
JsonPrimitive,
ends_with,
fractional,
normalize_numbers,
normalize_version,
sem_ver,
starts_with,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"~contextEnrichment",
"~deprecated",
"~fractional-v1",
"~fractional-v2",
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"~unixsocket",
"~deprecated",
"~fractional-v1",
"~fractional-v2",
]


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"~metadata",
"~deprecated",
"~fractional-v1",
"~fractional-v3",
]


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ def update_context(
evaluation_context: EvaluationContext, key: str, type_info: str, value: str
):
"""a context containing a key and value."""
if type_info == "String":
value = value.replace("\\\\", "\\")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
evaluation_context.attributes[key] = type_cast[type_info](value)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,14 @@ def assert_handler_run_within(event_type, event_handles, time: int):

for event in event_handles:
event_handles.remove(event)


@then(
parsers.cfparse(
"the {event_type} event handler should not have been executed",
)
)
def assert_handler_not_run(event_type: str, event_handles: list):
handles = assert_handlers(event_handles, event_type, 30)
found = any(h["type"] == event_type for h in handles)
assert not found
19 changes: 7 additions & 12 deletions providers/openfeature-provider-flagd/tests/test_targeting.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ def test_should_evaluate_valid_rule2(self):
logic = targeting(
"flagA", rule, EvaluationContext(attributes={"key": "bucketKeyB"})
)
assert logic == "red"
assert logic == "blue"

def test_should_evaluate_valid_rule_with_targeting_key(self):
rule = {
Expand All @@ -193,7 +193,7 @@ def test_should_evaluate_valid_rule_with_targeting_key(self):
}

logic = targeting("flagA", rule, EvaluationContext(targeting_key="bucketKeyB"))
assert logic == "red"
assert logic == "blue"

def test_should_evaluate_valid_rule_with_targeting_key_although_one_does_not_have_a_fraction(
self,
Expand All @@ -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 == "red"
assert logic == "blue"

def test_should_return_null_if_targeting_key_is_missing(self):
rule = {
Expand All @@ -225,7 +225,7 @@ def test_omitted_weight_defaults_to_1(self):
"fractional": [["red", 1], ["blue"]],
}
logic = targeting("flagA", rule, EvaluationContext(targeting_key="bucketKeyB"))
assert logic == "red"
assert logic == "blue"

def test_weight_zero_bucket_never_wins(self):
rule = {
Expand Down Expand Up @@ -278,7 +278,6 @@ def test_weight_as_string_is_invalid(self):
assert logic is None

def test_dynamic_weight_from_var_expression(self):
# seed="flagAkey" → bucket=55; rolloutPercent=70 → new-feature=[0,70), control=[70,100)
rule = {
"fractional": [
["new-feature", {"var": "rolloutPercent"}],
Expand All @@ -290,7 +289,7 @@ def test_dynamic_weight_from_var_expression(self):
rule,
EvaluationContext(targeting_key="key", attributes={"rolloutPercent": 70}),
)
assert logic == "new-feature"
assert logic == "control"

def test_total_weight_exceeds_max_int32_returns_none(self):
logic = targeting(
Expand Down Expand Up @@ -326,7 +325,6 @@ def test_variant_as_none(self):
assert logic is None

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],
Expand All @@ -336,7 +334,7 @@ def test_mixed_variant_types_all_participate(self):
],
}
logic = targeting("flagA", rule, EvaluationContext(targeting_key="key"))
assert logic == 1
assert logic is None

def test_nested_if_as_variant_name(self):
rule = {
Expand Down Expand Up @@ -411,9 +409,6 @@ def test_nested_var_as_variant_name_absent_key_resolves_to_none(self):
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],
Expand All @@ -429,7 +424,7 @@ def test_nested_fractional_as_variant_name(self):
],
}
logic = targeting("flagA", rule, EvaluationContext(targeting_key="key"))
assert logic == "hearts"
assert logic == "spades"

def test_nested_if_as_weight(self):
rule = {
Expand Down
3 changes: 2 additions & 1 deletion tools/openfeature-flagd-core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ dependencies = [
"mmh3>=5.0.0,<6.0.0",
"panzi-json-logic==1.0.1",
"semver>=3,<4",
"cbor2>=5.6.5,<6.0.0",
]
requires-python = ">=3.10"

Expand Down Expand Up @@ -66,7 +67,7 @@ module = [
ignore_missing_imports = true

[tool.pytest.ini_options]
addopts = "-m 'not fractional-v1'"
addopts = "-m 'not fractional-v1 and not fractional-v2'"

[tool.coverage.run]
omit = ["tests/**"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from collections.abc import Sequence
from dataclasses import dataclass

import cbor2
import mmh3
import semver

Expand All @@ -20,30 +21,58 @@ class Fraction:
weight: int = 1


def _resolve_bucket_by(data: dict, args: tuple) -> tuple[str | None, tuple]:
if isinstance(args[0], str):
def _resolve_bucket_by(data: dict, args: tuple) -> tuple[typing.Any, tuple]:
if not isinstance(args[0], (list, tuple)):
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.")
if targeting_key is None or not isinstance(targeting_key, str) or not targeting_key:
logger.error(
"No valid string 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:
flag_key = data.get("$flagd", {}).get("flagKey", "")
return [flag_key, targeting_key], args


def normalize_numbers(data: typing.Any) -> typing.Any:
"""
Recursively convert floats that have no fractional part into integers,
but only if they fit within the 64-bit signed or unsigned integer range [-2^63, 2^64 - 1].
This ensures consistency for integer representations while avoiding converting massive
floats into bignums, adhering to the flagd CBOR fractional specification.
"""
if isinstance(data, dict):
return {k: normalize_numbers(v) for k, v in data.items()}
elif isinstance(data, (list, tuple)):
return [normalize_numbers(v) for v in data]
elif (
isinstance(data, float) and data.is_integer() and -(2**63) <= data <= 2**64 - 1
):
return int(data)
return data


def fractional(data: dict, *args: typing.Any) -> str | float | int | bool | None:
if not args:
logger.error("No arguments provided to fractional operator.")
return None

bucket_by, args = _resolve_bucket_by(data, args)

if not bucket_by:
if bucket_by is None:
logger.error("No hashKey value resolved")
return None

hash_value = mmh3.hash(bucket_by, signed=False)
try:
bucket_by = normalize_numbers(bucket_by)
cbor_bytes = cbor2.dumps(bucket_by, canonical=True)
except Exception as e:
logger.error(f"Failed to encode bucket_by to CBOR: {e}")
return None

hash_value = mmh3.hash(cbor_bytes, signed=False)

total_weight = 0
fractions = []
Expand All @@ -61,6 +90,10 @@ def fractional(data: dict, *args: JsonLogicArg) -> str | float | int | bool | No
logger.error(f"Total fractional weight exceeds MaxInt32 ({MAX_WEIGHT_SUM:,}).")
return None

if total_weight <= 0:
logger.error("Total fractional weight must be greater than 0.")
return None

bucket = (hash_value * total_weight) >> 32

range_end = 0
Expand Down
86 changes: 86 additions & 0 deletions tools/openfeature-flagd-core/tests/test_targeting.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from openfeature.contrib.tools.flagd.core.targeting.custom_ops import (
ends_with,
fractional,
normalize_numbers,
sem_ver,
starts_with,
)
Expand Down Expand Up @@ -185,3 +186,88 @@ def test_fractional_deterministic(self) -> None:
r = fractional({}, "stable-key", ["x", 50], ["y", 50])
results.add(r)
assert len(results) == 1

def test_fractional_null_bucket_key(self) -> None:
"""Fractional with explicit null bucket key returns None."""
assert fractional({}, None, ["a", 50], ["b", 50]) is None

def test_fractional_shorthand_non_string_targeting_key(self) -> None:
"""Shorthand with non-string targetingKey (int, bool) returns None."""
int_data = {"targetingKey": 12345, "$flagd": {"flagKey": "my-flag"}}
assert fractional(int_data, ["a", 50], ["b", 50]) is None

bool_data = {"targetingKey": True, "$flagd": {"flagKey": "my-flag"}}
assert fractional(bool_data, ["a", 50], ["b", 50]) is None

def test_fractional_non_string_types(self) -> None:
"""Fractional should support int, float, bool, and dict (with nested lists)."""
for key in [123, 1.23, True, False, {"user": 1}, {"tags": ["tag1", "tag2"]}]:
result = fractional({}, key, ["a", 50], ["b", 50])
assert result in ("a", "b")

def test_fractional_top_level_array_not_explicit_key(self) -> None:
"""Top-level array is reserved for variant buckets (shorthand syntax) per ADR."""
# When no targetingKey in context, shorthand fails and returns None
result = fractional({}, ["tag1", "tag2"], ["a", 50], ["b", 50])
assert result is None

def test_fractional_float_int_equivalence(self) -> None:
"""1.0 and 1 must produce the exact same bucket assignment."""
res_float = fractional({}, 1.0, ["a", 50], ["b", 50])
res_int = fractional({}, 1, ["a", 50], ["b", 50])
assert res_float == res_int

def test_fractional_zero_values_equivalence(self) -> None:
"""0.0, -0.0, and 0 must produce the exact same bucket assignment."""
res_pos_zero = fractional({}, 0.0, ["a", 50], ["b", 50])
res_neg_zero = fractional({}, -0.0, ["a", 50], ["b", 50])
res_int_zero = fractional({}, 0, ["a", 50], ["b", 50])
assert res_pos_zero == res_neg_zero == res_int_zero

def test_fractional_dict_key_ordering(self) -> None:
"""Dicts with different key insertion order must evaluate identically."""
res_ab = fractional({}, {"a": 1, "b": 2}, ["a", 50], ["b", 50])
res_ba = fractional({}, {"b": 2, "a": 1}, ["a", 50], ["b", 50])
assert res_ab == res_ba

def test_fractional_zero_total_weight(self) -> None:
"""All-zero weights should return None."""
assert fractional({}, "user", ["a", 0], ["b", 0]) is None

def test_fractional_negative_weight_clamping(self) -> None:
"""Negative weights should be clamped to 0."""
assert fractional({}, "user", ["a", -50], ["b", 100]) == "b"

def test_fractional_cbor_serialization_failure(self) -> None:
"""If bucket_by cannot be serialized to CBOR, log error and return None."""

class Unserializable:
pass

assert fractional({}, Unserializable(), ["a", 50], ["b", 50]) is None


class TestNormalizeNumbers:
def test_float_to_int(self) -> None:
assert normalize_numbers(1.0) == 1
assert isinstance(normalize_numbers(1.0), int)
assert normalize_numbers(-2.0) == -2
assert isinstance(normalize_numbers(-2.0), int)

def test_float_with_fractional_part_unchanged(self) -> None:
assert normalize_numbers(1.25) == 1.25
assert isinstance(normalize_numbers(1.25), float)

def test_nested_dict_and_list(self) -> None:
data = {"a": 2.0, "b": [3.0, {"c": 4.5}]}
norm = normalize_numbers(data)
assert norm == {"a": 2, "b": [3, {"c": 4.5}]}
assert isinstance(norm["a"], int)
assert isinstance(norm["b"][0], int)
assert isinstance(norm["b"][1]["c"], float)

def test_out_of_range_float_stays_float(self) -> None:
huge = 1e100
norm = normalize_numbers(huge)
assert norm == huge
assert isinstance(norm, float)
Loading
Loading