Skip to content

Commit f34c06a

Browse files
aepfliclaude
andcommitted
fix: update flagd-core to match upstream fractional v2 and Python 3.10
- Implement fractional v2 bucketing algorithm (unsigned hash, integer arithmetic with bit-shift instead of percentage-based float) - Add MAX_WEIGHT_SUM overflow guard - Add negative weight clamping (max(0, weight)) - Add explicit bool-as-weight rejection - Support non-string variant types (str|float|int|bool|None) - Extract _resolve_bucket_by helper - Bump mmh3 dependency to >=5.0.0,<6.0.0 - Drop Python 3.9: update requires-python to >=3.10 for all tools packages Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Simon Schrottner <simon.schrottner@gmail.com>
1 parent e19ddf9 commit f34c06a

5 files changed

Lines changed: 57 additions & 45 deletions

File tree

tools/openfeature-flagd-api-testkit/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ dependencies = [
2121
"pytest>=8.4.0",
2222
"pytest-bdd>=8.1.0",
2323
]
24-
requires-python = ">=3.9"
24+
requires-python = ">=3.10"
2525

2626
[project.urls]
2727
Homepage = "https://github.com/open-feature/python-sdk-contrib"
@@ -46,7 +46,7 @@ data-dir = "src"
4646
[tool.mypy]
4747
mypy_path = "src"
4848
files = "src"
49-
python_version = "3.9"
49+
python_version = "3.10"
5050
namespace_packages = true
5151
explicit_package_bases = true
5252
local_partial_types = true

tools/openfeature-flagd-api/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ keywords = []
1818
dependencies = [
1919
"openfeature-sdk>=0.8.2",
2020
]
21-
requires-python = ">=3.9"
21+
requires-python = ">=3.10"
2222

2323
[project.urls]
2424
Homepage = "https://github.com/open-feature/python-sdk-contrib"
@@ -38,7 +38,7 @@ namespace = true
3838
[tool.mypy]
3939
mypy_path = "src"
4040
files = "src"
41-
python_version = "3.9"
41+
python_version = "3.10"
4242
namespace_packages = true
4343
explicit_package_bases = true
4444
local_partial_types = true

tools/openfeature-flagd-core/pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@ keywords = []
1818
dependencies = [
1919
"openfeature-flagd-api",
2020
"openfeature-sdk>=0.8.2",
21-
"mmh3>=4.1.0",
21+
"mmh3>=5.0.0,<6.0.0",
2222
"panzi-json-logic>=1.0.1",
2323
"semver>=3,<4",
2424
]
25-
requires-python = ">=3.9"
25+
requires-python = ">=3.10"
2626

2727
[project.urls]
2828
Homepage = "https://github.com/open-feature/python-sdk-contrib"
@@ -48,7 +48,7 @@ namespace = true
4848
[tool.mypy]
4949
mypy_path = "src"
5050
files = "src"
51-
python_version = "3.9"
51+
python_version = "3.10"
5252
namespace_packages = true
5353
explicit_package_bases = true
5454
local_partial_types = true

tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/targeting/custom_ops.py

Lines changed: 49 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,71 @@
11
import logging
22
import typing
3+
from collections.abc import Sequence
34
from dataclasses import dataclass
45

56
import mmh3
67
import 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

1114
logger = logging.getLogger("openfeature.contrib")
1215

1316

1417
@dataclass
1518
class 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

102114
def 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

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)