feat: Change fractional custom op from percentage-based to relative weighting. (#91)

Signed-off-by: Simon Schrottner <simon.schrottner@dynatrace.com>
This commit is contained in:
Simon Schrottner 2024-07-23 19:08:07 +02:00 committed by GitHub
parent 0f5b0ca501
commit 7b34822afd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 81 additions and 14 deletions

View File

@ -1,5 +1,6 @@
import logging
import typing
from dataclasses import dataclass
import mmh3
import semver
@ -10,6 +11,12 @@ JsonLogicArg = typing.Union[JsonPrimitive, typing.Sequence[JsonPrimitive]]
logger = logging.getLogger("openfeature.contrib")
@dataclass
class Fraction:
variant: str
weight: int = 1
def fractional(data: dict, *args: JsonLogicArg) -> typing.Optional[str]:
if not args:
logger.error("No arguments provided to fractional operator.")
@ -32,28 +39,51 @@ def fractional(data: dict, *args: JsonLogicArg) -> typing.Optional[str]:
return None
hash_ratio = abs(mmh3.hash(bucket_by)) / (2**31 - 1)
bucket = int(hash_ratio * 100)
bucket = hash_ratio * 100
total_weight = 0
fractions = []
for arg in args:
if (
not isinstance(arg, (tuple, list))
or len(arg) != 2
or not isinstance(arg[0], str)
or not isinstance(arg[1], int)
):
logger.error("Fractional variant weights must be (str, int) tuple")
return None
variant_weights: typing.Tuple[typing.Tuple[str, int]] = args # type: ignore[assignment]
fraction = _parse_fraction(arg)
if fraction:
fractions.append(fraction)
total_weight += fraction.weight
range_end = 0
for variant, weight in variant_weights:
range_end += weight
range_end: float = 0
for fraction in fractions:
range_end += fraction.weight * 100 / total_weight
if bucket < range_end:
return variant
return fraction.variant
return None
def _parse_fraction(arg: JsonLogicArg) -> typing.Optional[Fraction]:
if not isinstance(arg, (tuple, list)) or not arg:
logger.error(
"Fractional variant weights must be (str, int) tuple or [str] list"
)
return None
if not isinstance(arg[0], str):
logger.error(
"Fractional variant identifier (first element) isn't of type 'str'"
)
return None
if len(arg) >= 2 and not isinstance(arg[1], int):
logger.error(
"Fractional variant weight value (second element) isn't of type 'int'"
)
return None
fraction = Fraction(variant=arg[0])
if len(arg) >= 2:
fraction.weight = arg[1]
return fraction
def starts_with(data: dict, *args: JsonLogicArg) -> typing.Optional[bool]:
def f(s1: str, s2: str) -> bool:
return s1.startswith(s2)

View File

@ -0,0 +1,16 @@
{
"flags": {
"basic-flag": {
"state": "ENABLED",
"variants": {
"default": "default",
"true": "true",
"false": "false"
},
"defaultVariant": "default",
"targeting": {
"fractional": [[]]
}
}
}
}

View File

@ -0,0 +1,19 @@
{
"flags": {
"basic-flag": {
"state": "ENABLED",
"variants": {
"default": "default",
"true": "true",
"false": "false"
},
"defaultVariant": "default",
"targeting": {
"fractional": [
["a", "one"],
["b", "one"]
]
}
}
}
}

View File

@ -48,7 +48,9 @@ def test_file_load_errors(file_name: str):
"invalid-semver-args.json",
"invalid-stringcomp-args.json",
"invalid-fractional-args.json",
"invalid-fractional-args-wrong-content.json",
"invalid-fractional-weights.json",
"invalid-fractional-weights-strings.json",
],
)
def test_json_logic_parse_errors(file_name: str):