From 6d6c3f263d0ebe022714d9ecc2ea5bf1e5276454 Mon Sep 17 00:00:00 2001 From: Marcin Olko Date: Thu, 3 Sep 2026 13:32:26 +0000 Subject: [PATCH 1/2] feat(flagd-core): update fractional operator to CBOR encoding (v3) Update fractional bucketing in openfeature-flagd-core to use canonical CBOR encoding and unsigned 32-bit MurmurHash3 per the latest flagd specification. - Implement number normalization for float/int consistency per specification - Support non-string targeting keys and null bucketing keys returning None - Use [flagKey, targetingKey] CBOR array for shorthand bucketing syntax - Update flagd-testbed submodule to v3.10.1 and select @fractional-v3 tests - Add unit tests for null handling, zero equivalence, float equivalence, and ordering Signed-off-by: Marcin Olko --- .../openfeature/test-harness | 2 +- .../flagd/resolvers/process/custom_ops.py | 1 + .../tests/e2e/inprocess/conftest.py | 1 + .../tests/e2e/step/context_steps.py | 2 + tools/openfeature-flagd-core/pyproject.toml | 3 +- .../tools/flagd/core/targeting/custom_ops.py | 50 +++++++++--- .../tests/test_targeting.py | 78 +++++++++++++++++++ uv.lock | 50 +++++++++++- 8 files changed, 174 insertions(+), 13 deletions(-) diff --git a/providers/openfeature-provider-flagd/openfeature/test-harness b/providers/openfeature-provider-flagd/openfeature/test-harness index 7575a1dc..b308c1bb 160000 --- a/providers/openfeature-provider-flagd/openfeature/test-harness +++ b/providers/openfeature-provider-flagd/openfeature/test-harness @@ -1 +1 @@ -Subproject commit 7575a1dc45f176e57e809748a712a555e9aa5d11 +Subproject commit b308c1bbb12fc4511af6220f1b072a20a1a8831f diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/custom_ops.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/custom_ops.py index 9e834cfb..9f459b69 100644 --- a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/custom_ops.py +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/custom_ops.py @@ -6,6 +6,7 @@ JsonPrimitive, ends_with, fractional, + normalize_numbers, normalize_version, sem_ver, starts_with, diff --git a/providers/openfeature-provider-flagd/tests/e2e/inprocess/conftest.py b/providers/openfeature-provider-flagd/tests/e2e/inprocess/conftest.py index 804bea12..d900fdca 100644 --- a/providers/openfeature-provider-flagd/tests/e2e/inprocess/conftest.py +++ b/providers/openfeature-provider-flagd/tests/e2e/inprocess/conftest.py @@ -9,6 +9,7 @@ "~unixsocket", "~deprecated", "~fractional-v1", + "~fractional-v2", ] diff --git a/providers/openfeature-provider-flagd/tests/e2e/step/context_steps.py b/providers/openfeature-provider-flagd/tests/e2e/step/context_steps.py index 33a59b6b..e1f38247 100644 --- a/providers/openfeature-provider-flagd/tests/e2e/step/context_steps.py +++ b/providers/openfeature-provider-flagd/tests/e2e/step/context_steps.py @@ -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) diff --git a/tools/openfeature-flagd-core/pyproject.toml b/tools/openfeature-flagd-core/pyproject.toml index 4bfb5b8b..e312d0db 100644 --- a/tools/openfeature-flagd-core/pyproject.toml +++ b/tools/openfeature-flagd-core/pyproject.toml @@ -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" @@ -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/**"] diff --git a/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/targeting/custom_ops.py b/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/targeting/custom_ops.py index c47eec2e..ad643abf 100644 --- a/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/targeting/custom_ops.py +++ b/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/targeting/custom_ops.py @@ -3,6 +3,7 @@ from collections.abc import Sequence from dataclasses import dataclass +import cbor2 import mmh3 import semver @@ -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 = [] @@ -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 diff --git a/tools/openfeature-flagd-core/tests/test_targeting.py b/tools/openfeature-flagd-core/tests/test_targeting.py index 34a0f72e..dc7e186a 100644 --- a/tools/openfeature-flagd-core/tests/test_targeting.py +++ b/tools/openfeature-flagd-core/tests/test_targeting.py @@ -2,6 +2,7 @@ from openfeature.contrib.tools.flagd.core.targeting.custom_ops import ( ends_with, fractional, + normalize_numbers, sem_ver, starts_with, ) @@ -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) diff --git a/uv.lock b/uv.lock index 3168e251..32c8fef1 100644 --- a/uv.lock +++ b/uv.lock @@ -444,6 +444,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/1b/31cf2449da9a296f6c6c0002c7ae91a25c3a4bfef071763bbeb85300b402/cachebox-5.2.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:70c718f6bb77e6ba142b9a055b81ce85412a0c0e5e82a154489b45e6f91d09ec", size = 287614, upload-time = "2026-04-10T12:21:47.909Z" }, ] +[[package]] +name = "cbor2" +version = "5.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/cb/09939728be094d155b5d4ac262e39877875f5f7e36eea66beb359f647bd0/cbor2-5.9.0.tar.gz", hash = "sha256:85c7a46279ac8f226e1059275221e6b3d0e370d2bb6bd0500f9780781615bcea", size = 111231, upload-time = "2026-03-22T15:56:50.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/bf/12b337e5354e47f6378da18989480c0c1e2cc5fe9b865e6fab45d6332aa6/cbor2-5.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:55bea0dd9a7d354e35f4e5fe58ceab393e76962713749dc3a0a64a0e5d19545e", size = 70577, upload-time = "2026-03-22T15:55:54.174Z" }, + { url = "https://files.pythonhosted.org/packages/45/7b/74c524ce81c1ddc6c44b4865028ffb7d3a8e7ae653b1061650375a28cbb1/cbor2-5.9.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3095dc49e75572841a9534cbfdabc2a17487ea4ee33341436abc4a7ac7245a3a", size = 261074, upload-time = "2026-03-22T15:55:55.654Z" }, + { url = "https://files.pythonhosted.org/packages/4b/97/c496c71422b2ca18ff2acc5f49e8c45cd7294b7df1359eccec78021b428e/cbor2-5.9.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25bec7beb2089465382b1be72e78667fe9090598800826559c3e3008cf0db743", size = 255498, upload-time = "2026-03-22T15:55:57.256Z" }, + { url = "https://files.pythonhosted.org/packages/c2/40/9bd7e66dba7aea674a440e004faea406de42c49aeac23453954b67768532/cbor2-5.9.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cc5efec69055c3c470997935d95762be7e4bfd1248d88fb1a33bb7e0f45712e9", size = 255683, upload-time = "2026-03-22T15:55:58.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d1/fa3e158dbe4c08091495b720c604624b285bc272afdbcfac2150725d955b/cbor2-5.9.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:420d2490c7836c81151b4bd591c35cffc55391e33e7e333c50fda391bcea7d31", size = 250798, upload-time = "2026-03-22T15:55:59.953Z" }, + { url = "https://files.pythonhosted.org/packages/7c/16/3186084b441c4b0caebfaaa2c66a57bde82ceaacd469570c77c8030b6b95/cbor2-5.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:d1a21c006760f95acd9509cc5a7d15d6fc82e58f721f94fa9039b4e77189a6e5", size = 69436, upload-time = "2026-03-22T15:56:01.466Z" }, + { url = "https://files.pythonhosted.org/packages/36/1f/57d00cd13e0f9391bcd616795aa78409210a7464570fe21e3b9cd42eb5a7/cbor2-5.9.0-cp310-cp310-win_arm64.whl", hash = "sha256:08388ea54195738602b4c4999966bcaef6f0b17d293c9658658409d9fff96f57", size = 65312, upload-time = "2026-03-22T15:56:02.804Z" }, + { url = "https://files.pythonhosted.org/packages/43/aa/317c7118b8dda4c9563125c1a12c70c5b41e36677964a49c72b1aac061ec/cbor2-5.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0485d3372fc832c5e16d4eb45fa1a20fc53e806e6c29a1d2b0d3e176cedd52b9", size = 70578, upload-time = "2026-03-22T15:56:03.835Z" }, + { url = "https://files.pythonhosted.org/packages/31/43/fe29b1f897770011a5e7497f4523c2712282ee4a6cbf775ea6383fb7afb9/cbor2-5.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9d6e4e0f988b0e766509a8071975a8ee99f930e14a524620bf38083106158d2", size = 268738, upload-time = "2026-03-22T15:56:05.222Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/e494568f3d8aafbcdfe361df44c3bcf5cdab5183e25ea08e3d3f9fcf4075/cbor2-5.9.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5326336f633cc89dfe543c78829c16c3a6449c2c03277d1ddba99086c3323363", size = 262571, upload-time = "2026-03-22T15:56:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/42/2e/92acd6f87382fd44a34d9d7e85cc45372e6ba664040b72d1d9df648b25d0/cbor2-5.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5e702b02d42a5ace45425b595ffe70fe35aebaf9a3cdfdc2c758b6189c744422", size = 262356, upload-time = "2026-03-22T15:56:08.236Z" }, + { url = "https://files.pythonhosted.org/packages/3f/68/52c039a28688baeeb78b0be7483855e6c66ea05884a937444deede0c87b8/cbor2-5.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2372d357d403e7912f104ff085950ffc82a5854d6d717f1ca1ce16a40a0ef5a7", size = 257604, upload-time = "2026-03-22T15:56:09.835Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e4/10d96a7f73ed9227090ce6e3df5d73329eb6a267dab7d5b989e6fbf6c504/cbor2-5.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:1d02b65f070fd726bdc310d927228975bb655d155bf059b6eb7cacefb3dca86f", size = 69388, upload-time = "2026-03-22T15:56:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c6/eea5829aa5a649db540f47ea35f4bf2313383d28246f0cbc50432cfad6b3/cbor2-5.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:837754ece9052b3f607047e1741e5f852a538aa2b0ee3db11c82a8fa11804aa4", size = 65315, upload-time = "2026-03-22T15:56:12.326Z" }, + { url = "https://files.pythonhosted.org/packages/ee/39/72d8a5a4b06565561ec28f4fcb41aff7bb77f51705c01f00b8254a2aca4f/cbor2-5.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f223dffb1bcdd2764665f04c1152943d9daa4bc124a576cd8dee1cad4264313", size = 71223, upload-time = "2026-03-22T15:56:13.68Z" }, + { url = "https://files.pythonhosted.org/packages/09/fd/7ddf3d3153b54c69c3be77172b8d9aa3a9d74f62a7fbde614d53eaeed9a4/cbor2-5.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae6c706ac1d85a0b3cb3395308fd0c4d55e3202b4760773675957e93cdff45fc", size = 287865, upload-time = "2026-03-22T15:56:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/db/9d/7ede2cc42f9bb4260492e7d29d2aab781eacbbcfb09d983de1e695077199/cbor2-5.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4cd43d8fc374b31643b2830910f28177a606a7bc84975a62675dd3f2e320fc7b", size = 288246, upload-time = "2026-03-22T15:56:16.113Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9d/588ebc7c5bc5843f609b05fe07be8575c7dec987735b0bbc908ac9c1264a/cbor2-5.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4aa07b392cc3d76fb31c08a46a226b58c320d1c172ff3073e864409ced7bc50f", size = 280214, upload-time = "2026-03-22T15:56:17.519Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a1/6fc8f4b15c6a27e7fbb7966c30c2b4b18c274a3221fa2f5e6235502d34bc/cbor2-5.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:971d425b3a23b75953d8853d5f9911bdeefa09d759ee3b5e6b07b5ff3cbd9073", size = 282162, upload-time = "2026-03-22T15:56:18.975Z" }, + { url = "https://files.pythonhosted.org/packages/cf/20/9a22cfe08be16ddfeef2542cf4eeed1b29f3f57ddbba0b42f7e0bb8331fd/cbor2-5.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:34a6cb15e6ab6a8eae94ad2041731cd3ef786af43a8df99f847969af5b902ee7", size = 70049, upload-time = "2026-03-22T15:56:20.502Z" }, + { url = "https://files.pythonhosted.org/packages/c6/9e/695f92d09006614034e25a9f5b10620f3b219f79c1bec3c37b7c6f27a7a9/cbor2-5.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:7d1ddc4541e7367ac58c2470cc0df847f7137167fe4f5729e2d3cc0b993d7da4", size = 65382, upload-time = "2026-03-22T15:56:21.526Z" }, + { url = "https://files.pythonhosted.org/packages/81/c5/4901e21a8afe9448fd947b11e8f383903207cd6dd0800e5f5a386838de5b/cbor2-5.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fbb06f34aa645b4deca66643bba3d400d20c15312d1fe88d429be60c1ab50f27", size = 71284, upload-time = "2026-03-22T15:56:22.836Z" }, + { url = "https://files.pythonhosted.org/packages/1b/10/df643a381aebc3f05486de4813662bc58accb640fc3275cb276a75e89694/cbor2-5.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac684fe195c39821fca70d18afbf748f728aefbfbf88456018d299e559b8cae0", size = 287682, upload-time = "2026-03-22T15:56:24.024Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/8aa6b766059ae4a0ca1ec3ff96fe3823a69a7be880dba2e249f7fbe2700b/cbor2-5.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a54fbb32cb828c214f7f333a707e4aec61182e7efdc06ea5d9596d3ecee624a", size = 288009, upload-time = "2026-03-22T15:56:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/6236bc25c183a9cf7e8062e5dddf9eae9b0b14ebf14a58a69fe5a1e872c6/cbor2-5.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4753a6d1bc71054d9179557bc65740860f185095ccb401d46637fff028a5b3ec", size = 280437, upload-time = "2026-03-22T15:56:26.479Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0a/84328d23c3c68874ac6497edb9b1900579a1028efa54734df3f1762bbc15/cbor2-5.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:380e534482b843e43442b87d8777a7bf9bed20cb7526f89b780c3400f617304b", size = 282247, upload-time = "2026-03-22T15:56:28.644Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f6/89b4627e09d028c8e5fcaf7cb55f225c33ce6e037ec1844e65d02bcfa945/cbor2-5.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:dcf0f695873e5c94bd072d6af8698e72b8fb7f7a18f37e0bced1041b7111a6cf", size = 70089, upload-time = "2026-03-22T15:56:29.801Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7c/efadcd5f0102db692490e4e206988a2f98d39a09912090db497a2b800885/cbor2-5.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:f7c9751a9611601ab326d8f5837f01379195bbf06175fb4effeb552140e7c9e8", size = 65466, upload-time = "2026-03-22T15:56:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/08/7d/9ccc36d10ef96e6038e48046ebe1ce35a1e7814da0e1e204d09e6ef09b8d/cbor2-5.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23606d31ba1368bd1b6602e3020ee88fe9523ca80e8630faf6b2fc904fd84560", size = 71500, upload-time = "2026-03-22T15:56:31.876Z" }, + { url = "https://files.pythonhosted.org/packages/70/e1/a6cca2cc72e13f00030c6a649f57ae703eb2c620806ab70c40db8eab33fa/cbor2-5.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0322296b9d52f55880e300ba8ba09ecf644303b99b51138bbb1c0fb644fa7c3e", size = 286953, upload-time = "2026-03-22T15:56:33.292Z" }, + { url = "https://files.pythonhosted.org/packages/08/3c/24cd5ef488a957d90e016f200a3aad820e4c2f85edd61c9fe4523007a1ee/cbor2-5.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:422817286c1d0ce947fb2f7eca9212b39bddd7231e8b452e2d2cc52f15332dba", size = 285454, upload-time = "2026-03-22T15:56:34.703Z" }, + { url = "https://files.pythonhosted.org/packages/a4/35/dca96818494c0ba47cdd73e8d809b27fa91f8fa0ce32a068a09237687454/cbor2-5.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9a4907e0c3035bb8836116854ed8e56d8aef23909d601fa59706320897ec2551", size = 279441, upload-time = "2026-03-22T15:56:35.888Z" }, + { url = "https://files.pythonhosted.org/packages/a4/44/d3362378b16e53cf7e535a3f5aed8476e2109068154e24e31981ef5bde9e/cbor2-5.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fb7afe77f8d269e42d7c4b515c6fd14f1ccc0625379fb6829b269f493d16eddd", size = 279673, upload-time = "2026-03-22T15:56:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/43/d1/3533a697e5842fff7c2f64912eb251f8dcab3a8b5d88e228d6eebc3b5021/cbor2-5.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:86baf870d4c0bfc6f79de3801f3860a84ab76d9c8b0abb7f081f2c14c38d79d3", size = 71940, upload-time = "2026-03-22T15:56:38.366Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e2/c6ba75f3fb25dfa15ab6999cc8709c821987e9ed8e375d7f58539261bcb9/cbor2-5.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:7221483fad0c63afa4244624d552abf89d7dfdbc5f5edfc56fc1ff2b4b818975", size = 67639, upload-time = "2026-03-22T15:56:39.39Z" }, + { url = "https://files.pythonhosted.org/packages/42/ff/b83492b096fbef26e9cb62c1a4bf2d3cef579ea7b33138c6c37c4ae66f67/cbor2-5.9.0-py3-none-any.whl", hash = "sha256:27695cbd70c90b8de5c4a284642c2836449b14e2c2e07e3ffe0744cb7669a01b", size = 24627, upload-time = "2026-03-22T15:56:48.847Z" }, +] + [[package]] name = "certifi" version = "2026.4.22" @@ -843,7 +887,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1720,6 +1764,7 @@ name = "openfeature-flagd-core" version = "1.0.0" source = { editable = "tools/openfeature-flagd-core" } dependencies = [ + { name = "cbor2" }, { name = "mmh3" }, { name = "openfeature-flagd-api" }, { name = "openfeature-sdk" }, @@ -1739,6 +1784,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "cbor2", specifier = ">=5.6.5,<6.0.0" }, { name = "mmh3", specifier = ">=5.0.0,<6.0.0" }, { name = "openfeature-flagd-api", editable = "tools/openfeature-flagd-api" }, { name = "openfeature-sdk", specifier = ">=0.8.2" }, @@ -1868,7 +1914,7 @@ dev = [ [[package]] name = "openfeature-provider-flagd" -version = "0.5.1" +version = "0.5.2" source = { editable = "providers/openfeature-provider-flagd" } dependencies = [ { name = "cachebox" }, From 6d5a072a95b375a0964f6b86390762d1e4ed19f4 Mon Sep 17 00:00:00 2001 From: Marcin Olko Date: Fri, 4 Sep 2026 06:50:33 +0000 Subject: [PATCH 2/2] fix(flagd): resolve issues from bumped flagd-testbed, linting, and coverage Resolve test failures and incompatibilities introduced by bumping the flagd-testbed to v3.10.1, along with linting and coverage fixes: - Fix ruff SIM102 in tools/openfeature-flagd-core custom_ops.py - Add test for CBOR serialization failure to ensure 100% patch coverage - Update unit test expected values in openfeature-provider-flagd for CBOR bucketing - Configure file and RPC e2e test filters for fractional v2/v3 tags - Update default retry backoff max (5000) and grace period (10) per testbed v3.10.1 specification - Add missing 'error event handler should not have been executed' step definition from bumped testbed Signed-off-by: Marcin Olko --- .../contrib/provider/flagd/config.py | 5 +++-- .../tests/e2e/file/conftest.py | 1 + .../tests/e2e/rpc/conftest.py | 1 + .../tests/e2e/step/event_steps.py | 10 ++++++++++ .../tests/test_targeting.py | 19 +++++++------------ .../tools/flagd/core/targeting/custom_ops.py | 7 ++++--- .../tests/test_targeting.py | 8 ++++++++ 7 files changed, 34 insertions(+), 17 deletions(-) diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/config.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/config.py index 75a2cb4b..234e51a3 100644 --- a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/config.py +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/config.py @@ -28,8 +28,9 @@ class CacheType(Enum): DEFAULT_PORT_RPC = 8013 DEFAULT_RESOLVER_TYPE = ResolverType.RPC DEFAULT_RETRY_BACKOFF = 1000 -DEFAULT_RETRY_BACKOFF_MAX = 12000 -DEFAULT_RETRY_GRACE_PERIOD_SECONDS = 5 +DEFAULT_RETRY_BACKOFF_MAX = 5000 +DEFAULT_RETRY_GRACE_PERIOD_SECONDS = 10 + DEFAULT_STREAM_DEADLINE = 600000 DEFAULT_TLS = False DEFAULT_TLS_CERT: str | None = None diff --git a/providers/openfeature-provider-flagd/tests/e2e/file/conftest.py b/providers/openfeature-provider-flagd/tests/e2e/file/conftest.py index 0ad64b9e..048108d6 100644 --- a/providers/openfeature-provider-flagd/tests/e2e/file/conftest.py +++ b/providers/openfeature-provider-flagd/tests/e2e/file/conftest.py @@ -15,6 +15,7 @@ "~contextEnrichment", "~deprecated", "~fractional-v1", + "~fractional-v2", } diff --git a/providers/openfeature-provider-flagd/tests/e2e/rpc/conftest.py b/providers/openfeature-provider-flagd/tests/e2e/rpc/conftest.py index 074cd4ea..e5cb2d4e 100644 --- a/providers/openfeature-provider-flagd/tests/e2e/rpc/conftest.py +++ b/providers/openfeature-provider-flagd/tests/e2e/rpc/conftest.py @@ -11,6 +11,7 @@ "~metadata", "~deprecated", "~fractional-v1", + "~fractional-v3", ] diff --git a/providers/openfeature-provider-flagd/tests/e2e/step/event_steps.py b/providers/openfeature-provider-flagd/tests/e2e/step/event_steps.py index bbea93ac..fa890121 100644 --- a/providers/openfeature-provider-flagd/tests/e2e/step/event_steps.py +++ b/providers/openfeature-provider-flagd/tests/e2e/step/event_steps.py @@ -90,3 +90,13 @@ def assert_handler_run_within(event_type, event_handles, time: int): for event in event_handles: event_handles.remove(event) + + +@then( + parsers.cfparse( + "the {event_type} event handler should not have been executed", + ) +) +def assert_handler_not_run(event_type: str, event_handles: list): + found = any(h["type"] == event_type for h in event_handles) + assert not found diff --git a/providers/openfeature-provider-flagd/tests/test_targeting.py b/providers/openfeature-provider-flagd/tests/test_targeting.py index 8c1824ad..af3215ee 100644 --- a/providers/openfeature-provider-flagd/tests/test_targeting.py +++ b/providers/openfeature-provider-flagd/tests/test_targeting.py @@ -182,7 +182,7 @@ def test_should_evaluate_valid_rule2(self): logic = targeting( "flagA", rule, EvaluationContext(attributes={"key": "bucketKeyB"}) ) - assert logic == "red" + assert logic == "blue" def test_should_evaluate_valid_rule_with_targeting_key(self): rule = { @@ -193,7 +193,7 @@ def test_should_evaluate_valid_rule_with_targeting_key(self): } logic = targeting("flagA", rule, EvaluationContext(targeting_key="bucketKeyB")) - assert logic == "red" + assert logic == "blue" def test_should_evaluate_valid_rule_with_targeting_key_although_one_does_not_have_a_fraction( self, @@ -203,7 +203,7 @@ def test_should_evaluate_valid_rule_with_targeting_key_although_one_does_not_hav } logic = targeting("flagA", rule, EvaluationContext(targeting_key="bucketKeyB")) - assert logic == "red" + assert logic == "blue" def test_should_return_null_if_targeting_key_is_missing(self): rule = { @@ -225,7 +225,7 @@ def test_omitted_weight_defaults_to_1(self): "fractional": [["red", 1], ["blue"]], } logic = targeting("flagA", rule, EvaluationContext(targeting_key="bucketKeyB")) - assert logic == "red" + assert logic == "blue" def test_weight_zero_bucket_never_wins(self): rule = { @@ -278,7 +278,6 @@ def test_weight_as_string_is_invalid(self): assert logic is None def test_dynamic_weight_from_var_expression(self): - # seed="flagAkey" → bucket=55; rolloutPercent=70 → new-feature=[0,70), control=[70,100) rule = { "fractional": [ ["new-feature", {"var": "rolloutPercent"}], @@ -290,7 +289,7 @@ def test_dynamic_weight_from_var_expression(self): rule, EvaluationContext(targeting_key="key", attributes={"rolloutPercent": 70}), ) - assert logic == "new-feature" + assert logic == "control" def test_total_weight_exceeds_max_int32_returns_none(self): logic = targeting( @@ -326,7 +325,6 @@ def test_variant_as_none(self): assert logic is None def test_mixed_variant_types_all_participate(self): - # seed="flagAkey", 4 buckets weight 1 each → bucket=2 → third bucket → variant=1 (int) rule = { "fractional": [ ["clubs", 1], @@ -336,7 +334,7 @@ def test_mixed_variant_types_all_participate(self): ], } logic = targeting("flagA", rule, EvaluationContext(targeting_key="key")) - assert logic == 1 + assert logic is None def test_nested_if_as_variant_name(self): rule = { @@ -411,9 +409,6 @@ def test_nested_var_as_variant_name_absent_key_resolves_to_none(self): assert logic is None def test_nested_fractional_as_variant_name(self): - # json_logic evaluates the inner {"fractional":[...]} before the outer one sees it. - # Inner: seed="flagAkey", bucket=55 → hearts=[50,75) → "hearts". - # Outer: seed="flagAkey", bucket=1, buckets are ["clubs",1]=[0,1) and [inner,1]=[1,2) → inner & "hearts". inner = { "fractional": [ ["clubs", 25], @@ -429,7 +424,7 @@ def test_nested_fractional_as_variant_name(self): ], } logic = targeting("flagA", rule, EvaluationContext(targeting_key="key")) - assert logic == "hearts" + assert logic == "spades" def test_nested_if_as_weight(self): rule = { diff --git a/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/targeting/custom_ops.py b/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/targeting/custom_ops.py index ad643abf..5047bb97 100644 --- a/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/targeting/custom_ops.py +++ b/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/targeting/custom_ops.py @@ -47,9 +47,10 @@ def normalize_numbers(data: typing.Any) -> typing.Any: 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) + elif ( + isinstance(data, float) and data.is_integer() and -(2**63) <= data <= 2**64 - 1 + ): + return int(data) return data diff --git a/tools/openfeature-flagd-core/tests/test_targeting.py b/tools/openfeature-flagd-core/tests/test_targeting.py index dc7e186a..4e016b66 100644 --- a/tools/openfeature-flagd-core/tests/test_targeting.py +++ b/tools/openfeature-flagd-core/tests/test_targeting.py @@ -238,6 +238,14 @@ def test_fractional_negative_weight_clamping(self) -> None: """Negative weights should be clamped to 0.""" assert fractional({}, "user", ["a", -50], ["b", 100]) == "b" + def test_fractional_cbor_serialization_failure(self) -> None: + """If bucket_by cannot be serialized to CBOR, log error and return None.""" + + class Unserializable: + pass + + assert fractional({}, Unserializable(), ["a", 50], ["b", 50]) is None + class TestNormalizeNumbers: def test_float_to_int(self) -> None: