11import logging
22import typing
3+ from collections .abc import Sequence
34from dataclasses import dataclass
45
56import mmh3
67import semver
78
8- JsonPrimitive = typing .Union [str , bool , float , int ]
9- JsonLogicArg = typing .Union [JsonPrimitive , typing .Sequence [JsonPrimitive ]]
9+ MAX_WEIGHT_SUM = 2_147_483_647 # MaxInt32
10+
11+ JsonPrimitive : typing .TypeAlias = str | bool | float | int
12+ JsonLogicArg : typing .TypeAlias = JsonPrimitive | Sequence [JsonPrimitive ]
1013
1114logger = logging .getLogger ("openfeature.contrib" )
1215
1316
1417@dataclass
1518class Fraction :
16- variant : str
19+ variant : str | float | int | bool | None
1720 weight : int = 1
1821
1922
20- def fractional (data : dict , * args : JsonLogicArg ) -> typing .Optional [str ]:
23+ def _resolve_bucket_by (data : dict , args : tuple ) -> tuple [str | None , tuple ]:
24+ if isinstance (args [0 ], str ):
25+ return args [0 ], args [1 :]
26+
27+ seed = data .get ("$flagd" , {}).get ("flagKey" , "" )
28+ targeting_key = data .get ("targetingKey" )
29+ if not targeting_key :
30+ logger .error ("No targetingKey provided for fractional shorthand syntax." )
31+ return None , args
32+ return seed + targeting_key , args
33+
34+
35+ def fractional (data : dict , * args : JsonLogicArg ) -> str | float | int | bool | None :
2136 if not args :
2237 logger .error ("No arguments provided to fractional operator." )
2338 return None
2439
25- bucket_by = None
26- if isinstance (args [0 ], str ):
27- bucket_by = args [0 ]
28- args = args [1 :]
29- else :
30- seed = data .get ("$flagd" , {}).get ("flagKey" , "" )
31- targeting_key = data .get ("targetingKey" )
32- if not targeting_key :
33- logger .error ("No targetingKey provided for fractional shorthand syntax." )
34- return None
35- bucket_by = seed + targeting_key
40+ bucket_by , args = _resolve_bucket_by (data , args )
3641
3742 if not bucket_by :
3843 logger .error ("No hashKey value resolved" )
3944 return None
4045
41- hash_ratio = abs (mmh3 .hash (bucket_by )) / (2 ** 31 - 1 )
42- bucket = hash_ratio * 100
46+ hash_value = mmh3 .hash (bucket_by , signed = False )
4347
4448 total_weight = 0
4549 fractions = []
4650 try :
4751 for arg in args :
4852 fraction = _parse_fraction (arg )
49- if fraction :
50- fractions .append (fraction )
51- total_weight += fraction .weight
53+ fractions .append (fraction )
54+ total_weight += fraction .weight
5255
5356 except ValueError :
5457 logger .debug (f"Invalid { args } configuration" )
5558 return None
5659
57- range_end : float = 0
60+ if total_weight > MAX_WEIGHT_SUM :
61+ logger .error (f"Total fractional weight exceeds MaxInt32 ({ MAX_WEIGHT_SUM :,} )." )
62+ return None
63+
64+ bucket = (hash_value * total_weight ) >> 32
65+
66+ range_end = 0
5867 for fraction in fractions :
59- range_end += fraction .weight * 100 / total_weight
68+ range_end += fraction .weight
6069 if bucket < range_end :
6170 return fraction .variant
6271 return None
@@ -68,31 +77,34 @@ def _parse_fraction(arg: JsonLogicArg) -> Fraction:
6877 "Fractional variant weights must be (str, int) tuple or [str] list"
6978 )
7079
71- if not isinstance (arg [0 ], str ):
72- raise ValueError (
73- "Fractional variant identifier (first element) isn't of type 'str'"
74- )
80+ variant = arg [0 ]
7581
76- if len (arg ) >= 2 and not isinstance (arg [1 ], int ):
77- raise ValueError (
78- "Fractional variant weight value (second element) isn't of type 'int'"
79- )
82+ weight = None
83+ if len (arg ) == 2 :
84+ w = arg [1 ]
85+ if isinstance (w , bool ):
86+ raise ValueError ("Fractional weight value isn't of type 'int'" )
87+ elif isinstance (w , int ):
88+ weight = w
89+ else :
90+ raise ValueError ("Fractional weight value isn't of type 'int'" )
8091
81- fraction = Fraction (variant = arg [0 ])
82- if len (arg ) >= 2 :
83- fraction .weight = arg [1 ]
92+ fraction = Fraction (variant = variant )
93+ if weight is not None :
94+ # negative weights can be the result of rollout calculations, so we clamp to 0 rather than raising an error
95+ fraction .weight = max (0 , weight )
8496
8597 return fraction
8698
8799
88- def starts_with (data : dict , * args : JsonLogicArg ) -> typing . Optional [ bool ] :
100+ def starts_with (data : dict , * args : JsonLogicArg ) -> bool | None :
89101 def f (s1 : str , s2 : str ) -> bool :
90102 return s1 .startswith (s2 )
91103
92104 return string_comp (f , data , * args )
93105
94106
95- def ends_with (data : dict , * args : JsonLogicArg ) -> typing . Optional [ bool ] :
107+ def ends_with (data : dict , * args : JsonLogicArg ) -> bool | None :
96108 def f (s1 : str , s2 : str ) -> bool :
97109 return s1 .endswith (s2 )
98110
@@ -101,7 +113,7 @@ def f(s1: str, s2: str) -> bool:
101113
102114def string_comp (
103115 comparator : typing .Callable [[str , str ], bool ], data : dict , * args : JsonLogicArg
104- ) -> typing . Optional [ bool ] :
116+ ) -> bool | None :
105117 if not args :
106118 logger .error ("No arguments provided to string_comp operator." )
107119 return None
@@ -119,7 +131,7 @@ def string_comp(
119131 return comparator (arg1 , arg2 )
120132
121133
122- def sem_ver (data : dict , * args : JsonLogicArg ) -> typing . Optional [ bool ] : # noqa: C901
134+ def sem_ver (data : dict , * args : JsonLogicArg ) -> bool | None : # noqa: C901
123135 if not args :
124136 logger .error ("No arguments provided to sem_ver operator." )
125137 return None
0 commit comments