Skip to content
Draft
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 @@ -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 @@ -9,6 +9,7 @@
"~unixsocket",
"~deprecated",
"~fractional-v1",
"~fractional-v2",
]


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("\\\\", "\\")
evaluation_context.attributes[key] = type_cast[type_info](value)


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,57 @@ 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

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():
if -(2**63) <= data <= 2**64 - 1:
return int(data)
return data

def fractional(data: dict, *args: JsonLogicArg) -> str | float | int | bool | None:

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 +89,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
78 changes: 78 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,80 @@ 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"


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