Skip to content

Commit eb31b83

Browse files
authored
fix(flagd): improve targeting and fix fractional issue(#92) (#105)
Signed-off-by: Simon Schrottner <[email protected]>
1 parent ca76802 commit eb31b83

File tree

4 files changed

+303
-33
lines changed

4 files changed

+303
-33
lines changed

providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/in_process.py

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,18 @@
1-
import time
21
import typing
32

4-
from json_logic import builtins, jsonLogic # type: ignore[import-untyped]
5-
63
from openfeature.evaluation_context import EvaluationContext
74
from openfeature.exception import FlagNotFoundError, ParseError
85
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
96
from openfeature.provider.provider import AbstractProvider
107

118
from ..config import Config
12-
from .process.custom_ops import ends_with, fractional, sem_ver, starts_with
139
from .process.file_watcher import FileWatcherFlagStore
10+
from .process.targeting import targeting
1411

1512
T = typing.TypeVar("T")
1613

1714

1815
class InProcessResolver:
19-
OPERATORS: typing.ClassVar[dict] = {
20-
**builtins.BUILTINS,
21-
"fractional": fractional,
22-
"starts_with": starts_with,
23-
"ends_with": ends_with,
24-
"sem_ver": sem_ver,
25-
}
26-
2716
def __init__(self, config: Config, provider: AbstractProvider):
2817
self.config = config
2918
self.provider = provider
@@ -97,20 +86,15 @@ def _resolve(
9786
variant, value = flag.default
9887
return FlagResolutionDetails(value, variant=variant, reason=Reason.STATIC)
9988

100-
json_logic_context = evaluation_context.attributes if evaluation_context else {}
101-
json_logic_context["$flagd"] = {"flagKey": key, "timestamp": int(time.time())}
102-
json_logic_context["targetingKey"] = (
103-
evaluation_context.targeting_key if evaluation_context else None
104-
)
105-
variant = jsonLogic(flag.targeting, json_logic_context, self.OPERATORS)
89+
variant = targeting(flag.key, flag.targeting, evaluation_context)
90+
10691
if variant is None:
10792
variant, value = flag.default
10893
return FlagResolutionDetails(value, variant=variant, reason=Reason.DEFAULT)
10994
if not isinstance(variant, (str, bool)):
11095
raise ParseError(
11196
"Parsed JSONLogic targeting did not return a string or bool"
11297
)
113-
11498
variant, value = flag.get_variant(variant)
11599
if not value:
116100
raise ParseError(f"Resolved variant {variant} not in variants config.")

providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/custom_ops.py

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -43,39 +43,40 @@ def fractional(data: dict, *args: JsonLogicArg) -> typing.Optional[str]:
4343

4444
total_weight = 0
4545
fractions = []
46-
for arg in args:
47-
fraction = _parse_fraction(arg)
48-
if fraction:
49-
fractions.append(fraction)
50-
total_weight += fraction.weight
46+
try:
47+
for arg in args:
48+
fraction = _parse_fraction(arg)
49+
if fraction:
50+
fractions.append(fraction)
51+
total_weight += fraction.weight
52+
53+
except ValueError:
54+
logger.debug(f"Invalid {args} configuration")
55+
return None
5156

5257
range_end: float = 0
5358
for fraction in fractions:
5459
range_end += fraction.weight * 100 / total_weight
5560
if bucket < range_end:
5661
return fraction.variant
57-
5862
return None
5963

6064

61-
def _parse_fraction(arg: JsonLogicArg) -> typing.Optional[Fraction]:
62-
if not isinstance(arg, (tuple, list)) or not arg:
63-
logger.error(
65+
def _parse_fraction(arg: JsonLogicArg) -> Fraction:
66+
if not isinstance(arg, (tuple, list)) or not arg or len(arg) > 2:
67+
raise ValueError(
6468
"Fractional variant weights must be (str, int) tuple or [str] list"
6569
)
66-
return None
6770

6871
if not isinstance(arg[0], str):
69-
logger.error(
72+
raise ValueError(
7073
"Fractional variant identifier (first element) isn't of type 'str'"
7174
)
72-
return None
7375

7476
if len(arg) >= 2 and not isinstance(arg[1], int):
75-
logger.error(
77+
raise ValueError(
7678
"Fractional variant weight value (second element) isn't of type 'int'"
7779
)
78-
return None
7980

8081
fraction = Fraction(variant=arg[0])
8182
if len(arg) >= 2:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import time
2+
import typing
3+
4+
from json_logic import builtins, jsonLogic # type: ignore[import-untyped]
5+
from json_logic.types import JsonValue # type: ignore[import-untyped]
6+
7+
from openfeature.evaluation_context import EvaluationContext
8+
9+
from .custom_ops import (
10+
ends_with,
11+
fractional,
12+
sem_ver,
13+
starts_with,
14+
)
15+
16+
OPERATORS = {
17+
**builtins.BUILTINS,
18+
"fractional": fractional,
19+
"starts_with": starts_with,
20+
"ends_with": ends_with,
21+
"sem_ver": sem_ver,
22+
}
23+
24+
25+
def targeting(
26+
key: str,
27+
targeting: dict,
28+
evaluation_context: typing.Optional[EvaluationContext] = None,
29+
) -> JsonValue:
30+
json_logic_context = evaluation_context.attributes if evaluation_context else {}
31+
json_logic_context["$flagd"] = {"flagKey": key, "timestamp": int(time.time())}
32+
json_logic_context["targetingKey"] = (
33+
evaluation_context.targeting_key if evaluation_context else None
34+
)
35+
return jsonLogic(targeting, json_logic_context, OPERATORS)
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
import time
2+
import unittest
3+
from math import floor
4+
5+
import pytest
6+
from json_logic import builtins, jsonLogic # type: ignore[import-untyped]
7+
8+
from openfeature.contrib.provider.flagd.resolvers.process.custom_ops import (
9+
ends_with,
10+
fractional,
11+
sem_ver,
12+
starts_with,
13+
)
14+
from openfeature.contrib.provider.flagd.resolvers.process.targeting import targeting
15+
from openfeature.evaluation_context import EvaluationContext
16+
17+
OPERATORS = {
18+
**builtins.BUILTINS,
19+
"fractional": fractional,
20+
"starts_with": starts_with,
21+
"ends_with": ends_with,
22+
"sem_ver": sem_ver,
23+
}
24+
25+
flag_key = "flagKey"
26+
27+
28+
class BasicTests(unittest.TestCase):
29+
def test_should_inject_flag_key_as_a_property(self):
30+
rule = {"===": [{"var": "$flagd.flagKey"}, flag_key]}
31+
32+
result = targeting(flag_key, rule)
33+
34+
assert result
35+
36+
def test_should_inject_current_timestamp_as_a_property(self):
37+
ts = floor(time.time() / 1000)
38+
39+
rule = {">=": [{"var": "$flagd.timestamp"}, ts]}
40+
41+
assert targeting(flag_key, rule)
42+
43+
def test_should_override_injected_properties_if_already_present_in_context(self):
44+
rule = {"===": [{"var": "$flagd.flagKey"}, flag_key]}
45+
46+
ctx = {
47+
"$flagd": {
48+
"flagKey": "someOtherFlag",
49+
},
50+
}
51+
52+
assert targeting(flag_key, rule, EvaluationContext(attributes=ctx))
53+
54+
55+
class StringComparisonOperator(unittest.TestCase):
56+
def test_should_evaluate_starts_with_calls(self):
57+
rule = {"starts_with": [{"var": "email"}, "admin"]}
58+
context = {"email": "[email protected]"}
59+
60+
assert targeting(flag_key, rule, EvaluationContext(attributes=context))
61+
62+
def test_should_evaluate_ends_with_calls(self):
63+
rule = {"ends_with": [{"var": "email"}, "abc.com"]}
64+
context = {"email": "[email protected]"}
65+
66+
assert targeting(flag_key, rule, EvaluationContext(attributes=context))
67+
68+
def test_missing_targeting(self):
69+
rule = {"starts_with": [{"var": "email"}]}
70+
context = {"email": "[email protected]"}
71+
72+
assert not targeting(flag_key, rule, EvaluationContext(attributes=context))
73+
74+
def test_non_string_variable(self):
75+
rule = {"ends_with": [{"var": "number"}, "abc.com"]}
76+
context = {"number": 11111}
77+
78+
assert not targeting(flag_key, rule, EvaluationContext(attributes=context))
79+
80+
def test_non_string_comparator(self):
81+
rule = {"ends_with": [{"var": "email"}, 111111]}
82+
context = {"email": "[email protected]"}
83+
84+
assert not targeting(flag_key, rule, EvaluationContext(attributes=context))
85+
86+
87+
@pytest.mark.skip(
88+
"semvers are not working as expected, 'v' prefix is not valid within current implementation"
89+
)
90+
class SemVerOperator(unittest.TestCase):
91+
def test_should_support_equal_operator(self):
92+
rule = {"sem_ver": ["v1.2.3", "=", "1.2.3"]}
93+
94+
assert targeting(flag_key, rule)
95+
96+
def test_should_support_neq_operator(self):
97+
rule = {"sem_ver": ["v1.2.3", "!=", "1.2.4"]}
98+
99+
assert targeting(flag_key, rule)
100+
101+
def test_should_support_lt_operator(self):
102+
rule = {"sem_ver": ["v1.2.3", "<", "1.2.4"]}
103+
104+
assert targeting(flag_key, rule)
105+
106+
def test_should_support_lte_operator(self):
107+
rule = {"sem_ver": ["v1.2.3", "<=", "1.2.3"]}
108+
109+
assert targeting(flag_key, rule)
110+
111+
def test_should_support_gte_operator(self):
112+
rule = {"sem_ver": ["v1.2.3", ">=", "1.2.3"]}
113+
114+
assert targeting(flag_key, rule)
115+
116+
def test_should_support_gt_operator(self):
117+
rule = {"sem_ver": ["v1.2.4", ">", "1.2.3"]}
118+
119+
assert targeting(flag_key, rule)
120+
121+
def test_should_support_major_comparison_operator(self):
122+
rule = {"sem_ver": ["v1.2.3", "^", "v1.0.0"]}
123+
124+
assert targeting(flag_key, rule)
125+
126+
def test_should_support_minor_comparison_operator(self):
127+
rule = {"sem_ver": ["v5.0.3", "~", "v5.0.8"]}
128+
129+
assert targeting(flag_key, rule)
130+
131+
def test_should_handle_unknown_operator(self):
132+
rule = {"sem_ver": ["v1.0.0", "-", "v1.0.0"]}
133+
134+
assert targeting(flag_key, rule)
135+
136+
def test_should_handle_invalid_targetings(self):
137+
rule = {"sem_ver": ["myVersion_1", "=", "myVersion_1"]}
138+
139+
assert not targeting(flag_key, rule)
140+
141+
def test_should_validate_targetings(self):
142+
rule = {"sem_ver": ["myVersion_2", "+", "myVersion_1", "myVersion_1"]}
143+
144+
assert targeting(flag_key, rule)
145+
146+
147+
class FractionalOperator(unittest.TestCase):
148+
def test_should_evaluate_valid_rule(self):
149+
rule = {
150+
"fractional": [
151+
{"cat": [{"var": "$flagd.flagKey"}, {"var": "key"}]},
152+
["red", 50],
153+
["blue", 50],
154+
],
155+
}
156+
157+
logic = targeting(
158+
"flagA", rule, EvaluationContext(attributes={"key": "bucketKeyA"})
159+
)
160+
assert logic == "red"
161+
162+
def test_should_evaluate_valid_rule2(self):
163+
rule = {
164+
"fractional": [
165+
{"cat": [{"var": "$flagd.flagKey"}, {"var": "key"}]},
166+
["red", 50],
167+
["blue", 50],
168+
],
169+
}
170+
171+
logic = targeting(
172+
"flagA", rule, EvaluationContext(attributes={"key": "bucketKeyB"})
173+
)
174+
assert logic == "blue"
175+
176+
def test_should_evaluate_valid_rule_with_targeting_key(self):
177+
rule = {
178+
"fractional": [
179+
["red", 50],
180+
["blue", 50],
181+
],
182+
}
183+
184+
logic = targeting("flagA", rule, EvaluationContext(targeting_key="bucketKeyB"))
185+
assert logic == "blue"
186+
187+
def test_should_evaluate_valid_rule_with_targeting_key_although_one_does_not_have_a_fraction(
188+
self,
189+
):
190+
rule = {
191+
"fractional": [["red", 1], ["blue"]],
192+
}
193+
194+
logic = targeting("flagA", rule, EvaluationContext(targeting_key="bucketKeyB"))
195+
assert logic == "blue"
196+
197+
def test_should_return_null_if_targeting_key_is_missing(self):
198+
rule = {
199+
"fractional": [
200+
["red", 1],
201+
["blue", 1],
202+
],
203+
}
204+
205+
logic = jsonLogic(rule, {}, OPERATORS)
206+
assert logic is None
207+
208+
def test_bucket_sum_with_sum_bigger_than_100(self):
209+
rule = {
210+
"fractional": [
211+
["red", 55],
212+
["blue", 55],
213+
],
214+
}
215+
216+
logic = targeting("flagA", rule, EvaluationContext(targeting_key="key"))
217+
assert logic == "blue"
218+
219+
def test_bucket_sum_with_sum_lower_than_100(self):
220+
rule = {
221+
"fractional": [
222+
["red", 45],
223+
["blue", 45],
224+
],
225+
}
226+
227+
logic = targeting("flagA", rule, EvaluationContext(targeting_key="key"))
228+
assert logic == "blue"
229+
230+
def test_buckets_properties_to_have_variant_and_fraction(self):
231+
rule = {
232+
"fractional": [
233+
["red", 50],
234+
[100, 50],
235+
],
236+
}
237+
238+
logic = targeting("flagA", rule, EvaluationContext(targeting_key="key"))
239+
assert logic is None
240+
241+
def test_buckets_properties_to_have_variant_and_fraction2(self):
242+
rule = {
243+
"fractional": [
244+
["red", 45, 1256],
245+
["blue", 4, 455],
246+
],
247+
}
248+
249+
logic = targeting("flagA", rule, EvaluationContext(targeting_key="key"))
250+
assert logic is None

0 commit comments

Comments
 (0)