diff --git a/.gitmodules b/.gitmodules index ad5fe10a..4181e028 100644 --- a/.gitmodules +++ b/.gitmodules @@ -8,4 +8,4 @@ [submodule "providers/openfeature-provider-flagd/openfeature/test-harness"] path = providers/openfeature-provider-flagd/openfeature/test-harness url = https://github.com/open-feature/flagd-testbed.git - branch = v2.11.1 + branch = v3.5.0 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 123f0a84..1b5a2df8 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -3,5 +3,8 @@ "providers/openfeature-provider-flagd": "0.2.6", "providers/openfeature-provider-ofrep": "0.2.0", "providers/openfeature-provider-flipt": "0.1.3", - "providers/openfeature-provider-env-var": "0.1.0" + "providers/openfeature-provider-env-var": "0.1.0", + "tools/openfeature-flagd-api": "0.1.0", + "tools/openfeature-flagd-core": "0.1.0", + "tools/openfeature-flagd-api-testkit": "0.1.0" } diff --git a/providers/openfeature-provider-flagd/openfeature/test-harness b/providers/openfeature-provider-flagd/openfeature/test-harness index fe68e031..ff2fbe6c 160000 --- a/providers/openfeature-provider-flagd/openfeature/test-harness +++ b/providers/openfeature-provider-flagd/openfeature/test-harness @@ -1 +1 @@ -Subproject commit fe68e0310fd817a8f9bc1e2559f2277fed3aed34 +Subproject commit ff2fbe6c6584953cb2753ae9188d1cee14f7f57f diff --git a/providers/openfeature-provider-flagd/pyproject.toml b/providers/openfeature-provider-flagd/pyproject.toml index 087c760f..7befcbfa 100644 --- a/providers/openfeature-provider-flagd/pyproject.toml +++ b/providers/openfeature-provider-flagd/pyproject.toml @@ -18,11 +18,9 @@ classifiers = [ keywords = [] dependencies = [ "openfeature-sdk>=0.8.2", + "openfeature-flagd-core", "grpcio>=1.68.1", "protobuf>=5.26.1", - "mmh3>=4.1.0", - "panzi-json-logic>=1.0.1", - "semver>=3,<4", "pyyaml>=6.0.1", "cachebox; python_version >= '3.10'", # version `5.0.1` wheels for Python 3.9 on macOS were the last one for now, @@ -112,6 +110,9 @@ module = [ ] warn_unused_ignores = false +[tool.uv.sources] +openfeature-flagd-core = { workspace = true } + [project.scripts] # workaround while UV doesn't support scripts directly in the pyproject.toml # see: https://github.com/astral-sh/uv/issues/5903 diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/in_process.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/in_process.py index 9c6027b2..cf1a203a 100644 --- a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/in_process.py +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/in_process.py @@ -1,37 +1,41 @@ +import json import typing -from openfeature.contrib.provider.flagd.resolvers.process.connector.file_watcher import ( - FileWatcher, -) +from openfeature.contrib.tools.flagd.core import FlagdCore from openfeature.evaluation_context import EvaluationContext from openfeature.event import ProviderEventDetails -from openfeature.exception import ErrorCode, FlagNotFoundError, GeneralError, ParseError -from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType, Reason +from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType from ..config import Config from .process.connector import FlagStateConnector +from .process.connector.file_watcher import FileWatcher from .process.connector.grpc_watcher import GrpcWatcher -from .process.flags import Flag, FlagStore -from .process.targeting import targeting T = typing.TypeVar("T") -def _merge_metadata( - flag_metadata: typing.Optional[ - typing.Mapping[str, typing.Union[float, int, str, bool]] - ], - flag_set_metadata: typing.Optional[ - typing.Mapping[str, typing.Union[float, int, str, bool]] - ], -) -> typing.Mapping[str, typing.Union[float, int, str, bool]]: - metadata = {} if flag_set_metadata is None else dict(flag_set_metadata) +class _FlagStoreAdapter: + """Bridges FlagdCore with connectors that expect a FlagStore-like update() interface.""" - if flag_metadata is not None: - for key, value in flag_metadata.items(): - metadata[key] = value - - return metadata + def __init__( + self, + evaluator: FlagdCore, + emit_provider_configuration_changed: typing.Callable[ + [ProviderEventDetails], None + ], + ): + self.evaluator = evaluator + self.emit_provider_configuration_changed = emit_provider_configuration_changed + + def update(self, flags_data: dict) -> None: + json_str = json.dumps(flags_data) + changed_keys = self.evaluator.set_flags_and_get_changed_keys(json_str) + metadata = self.evaluator.get_flag_set_metadata() + self.emit_provider_configuration_changed( + ProviderEventDetails( + flags_changed=changed_keys, metadata=dict(metadata) + ) + ) class InProcessResolver: @@ -46,15 +50,25 @@ def __init__( ], ): self.config = config - self.flag_store = FlagStore(emit_provider_configuration_changed) + self.evaluator = FlagdCore() + + # Adapter lets connectors push flag data to FlagdCore via the + # same .update(dict) interface they used with the old FlagStore. + flag_store_adapter = _FlagStoreAdapter( + self.evaluator, emit_provider_configuration_changed + ) + self.connector: FlagStateConnector = ( FileWatcher( - self.config, self.flag_store, emit_provider_ready, emit_provider_error + self.config, + flag_store_adapter, # type: ignore[arg-type] + emit_provider_ready, + emit_provider_error, ) if self.config.offline_flag_source_path else GrpcWatcher( self.config, - self.flag_store, + flag_store_adapter, # type: ignore[arg-type] emit_provider_ready, emit_provider_error, emit_provider_stale, @@ -73,7 +87,7 @@ def resolve_boolean_details( default_value: bool, evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[bool]: - return self._resolve(key, default_value, evaluation_context) + return self.evaluator.resolve_boolean_value(key, default_value, evaluation_context) def resolve_string_details( self, @@ -81,7 +95,7 @@ def resolve_string_details( default_value: str, evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[str]: - return self._resolve(key, default_value, evaluation_context) + return self.evaluator.resolve_string_value(key, default_value, evaluation_context) def resolve_float_details( self, @@ -89,10 +103,7 @@ def resolve_float_details( default_value: float, evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[float]: - result = self._resolve(key, default_value, evaluation_context) - if isinstance(result.value, int): - result.value = float(result.value) - return result + return self.evaluator.resolve_float_value(key, default_value, evaluation_context) def resolve_integer_details( self, @@ -100,7 +111,7 @@ def resolve_integer_details( default_value: int, evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[int]: - return self._resolve(key, default_value, evaluation_context) + return self.evaluator.resolve_integer_value(key, default_value, evaluation_context) def resolve_object_details( self, @@ -112,75 +123,4 @@ def resolve_object_details( ) -> FlagResolutionDetails[ typing.Union[typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]] ]: - return self._resolve(key, default_value, evaluation_context) - - def _resolve( - self, - key: str, - default_value: T, - evaluation_context: typing.Optional[EvaluationContext] = None, - ) -> FlagResolutionDetails[T]: - flag = self.flag_store.get_flag(key) - if not flag: - raise FlagNotFoundError(f"Flag with key {key} not present in flag store.") - - metadata = _merge_metadata(flag.metadata, self.flag_store.flag_set_metadata) - - if flag.state == "DISABLED": - return FlagResolutionDetails( - default_value, flag_metadata=metadata, reason=Reason.DISABLED - ) - - if not flag.targeting: - return _default_resolve(flag, metadata, Reason.STATIC) - - try: - variant = targeting(flag.key, flag.targeting, evaluation_context) - if variant is None: - return _default_resolve(flag, metadata, Reason.DEFAULT) - - # convert to string to support shorthand (boolean in python is with capital T hence the special case) - if isinstance(variant, bool): - variant = str(variant).lower() - elif not isinstance(variant, str): - variant = str(variant) - - if variant not in flag.variants: - raise GeneralError( - f"Resolved variant {variant} not in variants config." - ) - - except ReferenceError: - raise ParseError(f"Invalid targeting {targeting}") from ReferenceError - - variant, value = flag.get_variant(variant) - if value is None: - raise GeneralError(f"Resolved variant {variant} not in variants config.") - - return FlagResolutionDetails( - value, - variant=variant, - reason=Reason.TARGETING_MATCH, - flag_metadata=metadata, - ) - - -def _default_resolve( - flag: Flag, - metadata: typing.Mapping[str, typing.Union[float, int, str, bool]], - reason: Reason, -) -> FlagResolutionDetails: - variant, value = flag.default - if variant is None: - return FlagResolutionDetails( - value, - variant=variant, - reason=Reason.ERROR, - error_code=ErrorCode.FLAG_NOT_FOUND, - flag_metadata=metadata, - ) - if variant not in flag.variants: - raise GeneralError(f"Resolved variant {variant} not in variants config.") - return FlagResolutionDetails( - value, variant=variant, flag_metadata=metadata, reason=reason - ) + return self.evaluator.resolve_object_value(key, default_value, evaluation_context) 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 936c72b6..22c4ae69 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 @@ -1,165 +1,13 @@ -import logging -import typing -from dataclasses import dataclass - -import mmh3 -import semver - -JsonPrimitive = typing.Union[str, bool, float, int] -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.") - return None - - bucket_by = None - if isinstance(args[0], str): - bucket_by = args[0] - args = args[1:] - else: - seed = data.get("$flagd", {}).get("flagKey", "") - targeting_key = data.get("targetingKey") - if not targeting_key: - logger.error("No targetingKey provided for fractional shorthand syntax.") - return None - bucket_by = seed + targeting_key - - if not bucket_by: - logger.error("No hashKey value resolved") - return None - - hash_ratio = abs(mmh3.hash(bucket_by)) / (2**31 - 1) - bucket = hash_ratio * 100 - - total_weight = 0 - fractions = [] - try: - for arg in args: - fraction = _parse_fraction(arg) - if fraction: - fractions.append(fraction) - total_weight += fraction.weight - - except ValueError: - logger.debug(f"Invalid {args} configuration") - return None - - range_end: float = 0 - for fraction in fractions: - range_end += fraction.weight * 100 / total_weight - if bucket < range_end: - return fraction.variant - return None - - -def _parse_fraction(arg: JsonLogicArg) -> Fraction: - if not isinstance(arg, (tuple, list)) or not arg or len(arg) > 2: - raise ValueError( - "Fractional variant weights must be (str, int) tuple or [str] list" - ) - - if not isinstance(arg[0], str): - raise ValueError( - "Fractional variant identifier (first element) isn't of type 'str'" - ) - - if len(arg) >= 2 and not isinstance(arg[1], int): - raise ValueError( - "Fractional variant weight value (second element) isn't of type 'int'" - ) - - 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) - - return string_comp(f, data, *args) - - -def ends_with(data: dict, *args: JsonLogicArg) -> typing.Optional[bool]: - def f(s1: str, s2: str) -> bool: - return s1.endswith(s2) - - return string_comp(f, data, *args) - - -def string_comp( - comparator: typing.Callable[[str, str], bool], data: dict, *args: JsonLogicArg -) -> typing.Optional[bool]: - if not args: - logger.error("No arguments provided to string_comp operator.") - return None - if len(args) != 2: - logger.error("Exactly 2 args expected for string_comp operator.") - return None - arg1, arg2 = args - if not isinstance(arg1, str): - logger.debug(f"incorrect argument for first argument, expected string: {arg1}") - return False - if not isinstance(arg2, str): - logger.debug(f"incorrect argument for second argument, expected string: {arg2}") - return False - - return comparator(arg1, arg2) - - -def sem_ver(data: dict, *args: JsonLogicArg) -> typing.Optional[bool]: # noqa: C901 - if not args: - logger.error("No arguments provided to sem_ver operator.") - return None - if len(args) != 3: - logger.error("Exactly 3 args expected for sem_ver operator.") - return None - - arg1, op, arg2 = args - - try: - v1 = parse_version(arg1) - v2 = parse_version(arg2) - except ValueError as e: - logger.exception(e) - return None - - if op == "=": - return v1 == v2 - elif op == "!=": - return v1 != v2 - elif op == "<": - return v1 < v2 - elif op == "<=": - return v1 <= v2 - elif op == ">": - return v1 > v2 - elif op == ">=": - return v1 >= v2 - elif op == "^": - return v1.major == v2.major - elif op == "~": - return v1.major == v2.major and v1.minor == v2.minor - else: - logger.error(f"Op not supported by sem_ver: {op}") - return None - - -def parse_version(arg: typing.Any) -> semver.Version: - version = str(arg) - if version.startswith(("v", "V")): - version = version[1:] - - return semver.Version.parse(version) +# Re-export from openfeature-flagd-core for backward compatibility. +# The canonical implementation now lives in openfeature.contrib.tools.flagd.core. +from openfeature.contrib.tools.flagd.core.targeting.custom_ops import ( # noqa: F401 + Fraction, + JsonLogicArg, + JsonPrimitive, + ends_with, + fractional, + parse_version, + sem_ver, + starts_with, + string_comp, +) diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/flags.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/flags.py index 3f448c7d..52e2e23f 100644 --- a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/flags.py +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/flags.py @@ -1,128 +1,36 @@ -import json -import re +# Backward-compatible re-exports from openfeature-flagd-core. +# The canonical implementation now lives in openfeature.contrib.tools.flagd.core. import typing -from dataclasses import dataclass +from openfeature.contrib.tools.flagd.core.model.flag import ( # noqa: F401 + Flag, + _validate_metadata, +) +from openfeature.contrib.tools.flagd.core.model.flag_store import ( + FlagStore as _CoreFlagStore, +) from openfeature.event import ProviderEventDetails -from openfeature.exception import ParseError -def _validate_metadata(key: str, value: typing.Union[float, int, str, bool]) -> None: - if key is None: - raise ParseError("Metadata key must be set") - elif not isinstance(key, str): - raise ParseError(f"Metadata key {key} must be of type str, but is {type(key)}") - elif not key: - raise ParseError("key must not be empty") - if value is None: - raise ParseError(f"Metadata value for key {key} must be set") - elif not isinstance(value, (float, int, str, bool)): - raise ParseError( - f"Metadata value {value} for key {key} must be of type float, int, str or bool, but is {type(value)}" - ) +class FlagStore(_CoreFlagStore): + """Backward-compatible FlagStore that supports an optional event callback.""" - -class FlagStore: def __init__( self, - emit_provider_configuration_changed: typing.Callable[ - [ProviderEventDetails], None - ], + emit_provider_configuration_changed: typing.Optional[ + typing.Callable[[ProviderEventDetails], None] + ] = None, ): - self.emit_provider_configuration_changed = emit_provider_configuration_changed - self.flags: typing.Mapping[str, Flag] = {} - self.flag_set_metadata: typing.Mapping[ - str, typing.Union[float, int, str, bool] - ] = {} - - def get_flag(self, key: str) -> typing.Optional["Flag"]: - return self.flags.get(key) - - def update(self, flags_data: dict) -> None: - flags = flags_data.get("flags", {}) - metadata = flags_data.get("metadata", {}) - evaluators: typing.Optional[dict] = flags_data.get("$evaluators") - if evaluators: - transposed = json.dumps(flags) - for name, rule in evaluators.items(): - transposed = re.sub( - rf"{{\s*\"\$ref\":\s*\"{name}\"\s*}}", json.dumps(rule), transposed + super().__init__() + self._emit_provider_configuration_changed = emit_provider_configuration_changed + + def update(self, flags_data: dict) -> typing.List[str]: + changed_keys = super().update(flags_data) + if self._emit_provider_configuration_changed is not None: + self._emit_provider_configuration_changed( + ProviderEventDetails( + flags_changed=list(self.flags.keys()), + metadata=dict(self.flag_set_metadata), ) - flags = json.loads(transposed) - - if not isinstance(flags, dict): - raise ParseError("`flags` key of configuration must be a dictionary") - if not isinstance(metadata, dict): - raise ParseError("`metadata` key of configuration must be a dictionary") - for key, value in metadata.items(): - _validate_metadata(key, value) - - self.flags = {key: Flag.from_dict(key, data) for key, data in flags.items()} - self.flag_set_metadata = metadata - - self.emit_provider_configuration_changed( - ProviderEventDetails( - flags_changed=list(self.flags.keys()), metadata=metadata ) - ) - - -@dataclass -class Flag: - key: str - state: str - variants: typing.Mapping[str, typing.Any] - default_variant: typing.Optional[typing.Union[bool, str]] = None - targeting: typing.Optional[dict] = None - metadata: typing.Optional[ - typing.Mapping[str, typing.Union[float, int, str, bool]] - ] = None - - def __post_init__(self) -> None: - if not self.state or not (self.state == "ENABLED" or self.state == "DISABLED"): - raise ParseError("Incorrect 'state' value provided in flag config") - - if not self.variants or not isinstance(self.variants, dict): - raise ParseError("Incorrect 'variants' value provided in flag config") - - if self.default_variant and not isinstance(self.default_variant, (str, bool)): - raise ParseError("Incorrect 'defaultVariant' value provided in flag config") - - if self.metadata: - if not isinstance(self.metadata, dict): - raise ParseError("Flag metadata is not a valid json object") - for key, value in self.metadata.items(): - _validate_metadata(key, value) - - @classmethod - def from_dict(cls, key: str, data: dict) -> "Flag": - if "defaultVariant" in data: - data["default_variant"] = data["defaultVariant"] - if data["default_variant"] == "": - data["default_variant"] = None - del data["defaultVariant"] - - data.pop("source", None) - data.pop("selector", None) - try: - flag = cls(key=key, **data) - return flag - except ParseError as parseError: - raise parseError - except Exception as err: - raise ParseError from err - - @property - def default(self) -> tuple[typing.Optional[str], typing.Any]: - return self.get_variant(self.default_variant) - - def get_variant( - self, variant_key: typing.Union[str, bool, None] - ) -> tuple[typing.Optional[str], typing.Any]: - if isinstance(variant_key, bool): - variant_key = str(variant_key).lower() - - if not variant_key: - return None, None - - return variant_key, self.variants.get(variant_key) + return changed_keys diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/targeting.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/targeting.py index 079a8aa1..e172e5cf 100644 --- a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/targeting.py +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/targeting.py @@ -1,43 +1,6 @@ -from __future__ import annotations - -import time -import typing - -from json_logic import builtins, jsonLogic -from json_logic.types import JsonValue - -from openfeature.evaluation_context import EvaluationContext -from openfeature.exception import ParseError - -from .custom_ops import ( - ends_with, - fractional, - sem_ver, - starts_with, +# Re-export from openfeature-flagd-core for backward compatibility. +# The canonical implementation now lives in openfeature.contrib.tools.flagd.core. +from openfeature.contrib.tools.flagd.core.targeting.targeting import ( # noqa: F401 + OPERATORS, + targeting, ) - -OPERATORS = { - **builtins.BUILTINS, - "fractional": fractional, - "starts_with": starts_with, - "ends_with": ends_with, - "sem_ver": sem_ver, -} - - -def targeting( - key: str, - targeting: dict, - evaluation_context: typing.Optional[EvaluationContext] = None, -) -> JsonValue: - if not isinstance(targeting, dict): - raise ParseError(f"Invalid 'targeting' value in flag: {targeting}") - - json_logic_context: dict[str, typing.Any] = ( - dict(evaluation_context.attributes) if evaluation_context else {} - ) - json_logic_context["$flagd"] = {"flagKey": key, "timestamp": int(time.time())} - json_logic_context["targetingKey"] = ( - evaluation_context.targeting_key if evaluation_context else None - ) - return jsonLogic(targeting, json_logic_context, OPERATORS) diff --git a/providers/openfeature-provider-flagd/tests/test_in_process.py b/providers/openfeature-provider-flagd/tests/test_in_process.py index cec882f8..9d85450c 100644 --- a/providers/openfeature-provider-flagd/tests/test_in_process.py +++ b/providers/openfeature-provider-flagd/tests/test_in_process.py @@ -1,15 +1,15 @@ +import json from unittest.mock import Mock, create_autospec import pytest from openfeature.contrib.provider.flagd.config import Config from openfeature.contrib.provider.flagd.resolvers.in_process import InProcessResolver -from openfeature.contrib.provider.flagd.resolvers.process.flags import Flag, FlagStore from openfeature.evaluation_context import EvaluationContext from openfeature.exception import FlagNotFoundError, GeneralError -def targeting(): +def _targeting_rule(): return { "if": [ {"==": [{"var": "targetingKey"}, "target_variant"]}, @@ -19,6 +19,19 @@ def targeting(): } +def _flag_config(variants, targeting=None, state="ENABLED", default_variant="default_variant"): + return { + "flags": { + "flag": { + "state": state, + "variants": variants, + "defaultVariant": default_variant, + **({"targeting": targeting} if targeting else {}), + } + } + } + + def context(targeting_key): return EvaluationContext(targeting_key=targeting_key) @@ -28,22 +41,6 @@ def config(): return create_autospec(Config) -@pytest.fixture -def flag_store(): - return create_autospec(FlagStore) - - -@pytest.fixture -def flag(): - return Flag( - key="flag", - state="ENABLED", - variants={"default_variant": False, "target_variant": True}, - default_variant="default_variant", - targeting=targeting(), - ) - - @pytest.fixture def resolver(config): config.offline_flag_source_path = "flag.json" @@ -58,26 +55,30 @@ def resolver(config): def test_resolve_boolean_details_flag_not_found(resolver): - resolver.flag_store.get_flag = Mock(return_value=None) with pytest.raises(FlagNotFoundError): resolver.resolve_boolean_details("nonexistent_flag", False) -def test_resolve_boolean_details_disabled_flag(flag, resolver): - flag.state = "DISABLED" - resolver.flag_store.get_flag = Mock(return_value=flag) +def test_resolve_boolean_details_disabled_flag(resolver): + flags = _flag_config( + variants={"default_variant": False, "target_variant": True}, + state="DISABLED", + ) + resolver.evaluator.set_flags(json.dumps(flags)) - result = resolver.resolve_boolean_details("disabled_flag", False) + result = resolver.resolve_boolean_details("flag", False) assert result.reason == "DISABLED" assert result.variant is None assert not result.value -def test_resolve_boolean_details_invalid_variant(resolver, flag): - flag.targeting = {"var": ["targetingKey", "invalid_variant"]} - - resolver.flag_store.get_flag = Mock(return_value=flag) +def test_resolve_boolean_details_invalid_variant(resolver): + flags = _flag_config( + variants={"default_variant": False, "target_variant": True}, + targeting={"var": ["targetingKey", "invalid_variant"]}, + ) + resolver.evaluator.set_flags(json.dumps(flags)) with pytest.raises(GeneralError): resolver.resolve_boolean_details("flag", False) @@ -101,7 +102,7 @@ def test_resolve_boolean_details_invalid_variant(resolver, flag): ( { "variants": {"default_variant": False, "target_variant": True}, - "targeting": targeting(), + "targeting": _targeting_rule(), }, { "context": context("no_target_variant"), @@ -113,7 +114,7 @@ def test_resolve_boolean_details_invalid_variant(resolver, flag): ( { "variants": {"default_variant": False, "target_variant": True}, - "targeting": targeting(), + "targeting": _targeting_rule(), }, { "context": context("target_variant"), @@ -125,7 +126,7 @@ def test_resolve_boolean_details_invalid_variant(resolver, flag): ( { "variants": {"default_variant": "default", "target_variant": "target"}, - "targeting": targeting(), + "targeting": _targeting_rule(), }, { "context": context("target_variant"), @@ -141,7 +142,7 @@ def test_resolve_boolean_details_invalid_variant(resolver, flag): ( { "variants": {"default_variant": 1.0, "target_variant": 2.0}, - "targeting": targeting(), + "targeting": _targeting_rule(), }, { "context": context("target_variant"), @@ -153,7 +154,7 @@ def test_resolve_boolean_details_invalid_variant(resolver, flag): ( { "variants": {"default_variant": True, "target_variant": False}, - "targeting": targeting(), + "targeting": _targeting_rule(), }, { "context": context("target_variant"), @@ -165,7 +166,7 @@ def test_resolve_boolean_details_invalid_variant(resolver, flag): ( { "variants": {"default_variant": 10, "target_variant": 0}, - "targeting": targeting(), + "targeting": _targeting_rule(), }, { "context": context("target_variant"), @@ -177,7 +178,7 @@ def test_resolve_boolean_details_invalid_variant(resolver, flag): ( { "variants": {"default_variant": {}, "target_variant": {}}, - "targeting": targeting(), + "targeting": _targeting_rule(), }, { "context": context("target_variant"), @@ -200,14 +201,15 @@ def test_resolve_boolean_details_invalid_variant(resolver, flag): ) def test_resolver_details( resolver, - flag, input_config, resolve_config, expected, ): - flag.variants = input_config["variants"] - flag.targeting = input_config["targeting"] - resolver.flag_store.get_flag = Mock(return_value=flag) + flags = _flag_config( + variants=input_config["variants"], + targeting=input_config.get("targeting"), + ) + resolver.evaluator.set_flags(json.dumps(flags)) result = getattr(resolver, resolve_config["method"])( "flag", resolve_config["default_value"], resolve_config["context"] diff --git a/pyproject.toml b/pyproject.toml index 783df689..b7086cd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,10 @@ dependencies = [ "openfeature-provider-flagd", "openfeature-provider-flipt", "openfeature-provider-ofrep", + # tools + "openfeature-flagd-api", + "openfeature-flagd-core", + "openfeature-flagd-api-testkit", ] [dependency-groups] @@ -31,11 +35,16 @@ openfeature-provider-env-var = { workspace = true } openfeature-provider-flagd = { workspace = true } openfeature-provider-flipt = { workspace = true } openfeature-provider-ofrep = { workspace = true } +# tools +openfeature-flagd-api = { workspace = true } +openfeature-flagd-core = { workspace = true } +openfeature-flagd-api-testkit = { workspace = true } [tool.uv.workspace] members = [ "hooks/*", "providers/*", + "tools/*", ] [tool.ruff] diff --git a/release-please-config.json b/release-please-config.json index f4be87ac..74f352ea 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -52,6 +52,33 @@ "extra-files": [ "README.md" ] + }, + "tools/openfeature-flagd-api": { + "package-name": "openfeature-flagd-api", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [ + "README.md" + ] + }, + "tools/openfeature-flagd-core": { + "package-name": "openfeature-flagd-core", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [ + "README.md" + ] + }, + "tools/openfeature-flagd-api-testkit": { + "package-name": "openfeature-flagd-api-testkit", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [ + "README.md" + ] } }, "changelog-sections": [ diff --git a/tools/openfeature-flagd-api-testkit/CHANGELOG.md b/tools/openfeature-flagd-api-testkit/CHANGELOG.md new file mode 100644 index 00000000..825c32f0 --- /dev/null +++ b/tools/openfeature-flagd-api-testkit/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/tools/openfeature-flagd-api-testkit/LICENSE b/tools/openfeature-flagd-api-testkit/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/tools/openfeature-flagd-api-testkit/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tools/openfeature-flagd-api-testkit/README.md b/tools/openfeature-flagd-api-testkit/README.md new file mode 100644 index 00000000..88a001f3 --- /dev/null +++ b/tools/openfeature-flagd-api-testkit/README.md @@ -0,0 +1,32 @@ +# OpenFeature flagd API Testkit + +A compliance test suite for the flagd Evaluator protocol. This package bundles Gherkin feature files and pytest-bdd step definitions so that any implementation of the Evaluator protocol can run the full compliance suite. + +## Usage + +1. Add `openfeature-flagd-api-testkit` as a test dependency. + +2. Create a `conftest.py` that provides an `evaluator` fixture and imports the bundled steps: + +```python +import pytest +from openfeature.contrib.tools.flagd.api.testkit import load_testkit_flags +from openfeature.contrib.tools.flagd.api.testkit.steps import * # noqa: F401, F403 + + +@pytest.fixture +def evaluator(): + """Provide your Evaluator implementation here.""" + e = MyEvaluator() + e.set_flags(load_testkit_flags()) + return e +``` + +3. Create a test file that registers scenarios from the bundled feature files: + +```python +from pytest_bdd import scenarios +from openfeature.contrib.tools.flagd.api.testkit import get_features_path + +scenarios(get_features_path()) +``` diff --git a/tools/openfeature-flagd-api-testkit/pyproject.toml b/tools/openfeature-flagd-api-testkit/pyproject.toml new file mode 100644 index 00000000..a961cc74 --- /dev/null +++ b/tools/openfeature-flagd-api-testkit/pyproject.toml @@ -0,0 +1,67 @@ +[build-system] +requires = ["uv_build~=0.9.0"] +build-backend = "uv_build" + +[project] +name = "openfeature-flagd-api-testkit" +version = "0.1.0" +description = "OpenFeature flagd evaluator API compliance test suite" +readme = "README.md" +authors = [{ name = "OpenFeature", email = "openfeature-core@groups.io" }] +license = { file = "LICENSE" } +classifiers = [ + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", +] +keywords = [] +dependencies = [ + "openfeature-flagd-api", + "openfeature-sdk>=0.8.2", + "pytest>=8.4.0", + "pytest-bdd>=8.1.0", +] +requires-python = ">=3.9" + +[project.urls] +Homepage = "https://github.com/open-feature/python-sdk-contrib" + +[dependency-groups] +dev = [ + "coverage[toml]>=7.10.0,<8.0.0", + "mypy>=1.18.0,<2.0.0", + "openfeature-flagd-core", +] + +[tool.uv.sources] +openfeature-flagd-api = { workspace = true } +openfeature-flagd-core = { workspace = true } + +[tool.uv.build-backend] +module-name = "openfeature" +module-root = "src" +namespace = true +data-dir = "src" + +[tool.mypy] +mypy_path = "src" +files = "src" +python_version = "3.9" +namespace_packages = true +explicit_package_bases = true +local_partial_types = true +allow_redefinition_new = true +fixed_format_cache = true +pretty = true +strict = true +disallow_any_generics = false + +[tool.coverage.run] +omit = ["tests/**"] + +[project.scripts] +cov-report = "scripts.scripts:cov_report" +cov = "scripts.scripts:cov" +mypy-check = "scripts.scripts:mypy" +test = "scripts.scripts:test" +test-cov = "scripts.scripts:test_cov" diff --git a/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/__init__.py b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/__init__.py new file mode 100644 index 00000000..8561324e --- /dev/null +++ b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/__init__.py @@ -0,0 +1,14 @@ +import importlib.resources +import typing + + +def get_features_path() -> str: + """Return the path to the bundled feature files directory.""" + ref = importlib.resources.files("openfeature.contrib.tools.flagd.testkit") / "features" + return str(ref) + + +def load_testkit_flags() -> str: + """Load the bundled testkit-flags.json as a string.""" + ref = importlib.resources.files("openfeature.contrib.tools.flagd.testkit") / "flag_data" / "testkit-flags.json" + return ref.read_text(encoding="utf-8") diff --git a/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/errors.feature b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/errors.feature new file mode 100644 index 00000000..2ff2f54a --- /dev/null +++ b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/errors.feature @@ -0,0 +1,18 @@ +Feature: Evaluator error handling + + # Validates that the evaluator returns the correct error codes for + # well-known error conditions: FLAG_NOT_FOUND and TYPE_MISMATCH. + # Flags are configured in evaluator/flags/testkit-flags.json. + + Background: + Given an evaluator + + Scenario: Flag not found + Given a String-flag with key "missing-flag" and a fallback value "uh-oh" + When the flag was evaluated with details + Then the error-code should be "FLAG_NOT_FOUND" + + Scenario: Type mismatch + Given a Integer-flag with key "wrong-flag" and a fallback value "13" + When the flag was evaluated with details + Then the error-code should be "TYPE_MISMATCH" diff --git a/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/evaluation.feature b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/evaluation.feature new file mode 100644 index 00000000..701291a9 --- /dev/null +++ b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/evaluation.feature @@ -0,0 +1,27 @@ +Feature: Evaluator basic flag evaluation + + # Validates basic static flag resolution for all supported types. + # Flags are configured in evaluator/flags/testkit-flags.json. + + Scenario Outline: Resolve basic values + Given an evaluator + And a -flag with key "" and a fallback value "" + When the flag was evaluated with details + Then the resolved details value should be "" + + Examples: Boolean evaluations + | key | type | default | resolved_value | + | boolean-flag | Boolean | false | true | + + Examples: String evaluations + | key | type | default | resolved_value | + | string-flag | String | bye | hi | + + Examples: Number evaluations + | key | type | default | resolved_value | + | integer-flag | Integer | 1 | 10 | + | float-flag | Float | 0.1 | 0.5 | + + Examples: Object evaluations + | key | type | default | resolved_value | + | object-flag | Object | {} | {\"showImages\": true,\"title\": \"Check out these pics!\",\"imagesPerPage\": 100} | diff --git a/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/evaluator-refs.feature b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/evaluator-refs.feature new file mode 100644 index 00000000..0d8467d2 --- /dev/null +++ b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/evaluator-refs.feature @@ -0,0 +1,15 @@ +Feature: Evaluator evaluator refs + + # Tests that $ref shared targeting rules work correctly. + + Scenario Outline: Evaluator reuse via $ref + Given an evaluator + And a String-flag with key "" and a fallback value "fallback" + And a context containing a key "email", with type "String" and with value "ballmer@macrosoft.com" + When the flag was evaluated with details + Then the resolved details value should be "" + + Examples: + | key | value | + | some-email-targeted-flag | hi | + | some-other-email-targeted-flag | yes | diff --git a/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/fractional.feature b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/fractional.feature new file mode 100644 index 00000000..f6a363bf --- /dev/null +++ b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/fractional.feature @@ -0,0 +1,190 @@ +@fractional +Feature: Evaluator fractional operator + + # Tests the fractional bucketing operator for consistent user assignment. + # @fractional-v1: legacy float-based bucketing (abs(hash) / i32::MAX * 100) + # @fractional-v2: high-precision integer bucketing ((hash * totalWeight) >> 32) + + Background: + Given an evaluator + + Scenario Outline: Fractional operator + Given a String-flag with key "fractional-flag" and a fallback value "fallback" + And a context containing a nested property with outer key "user" and inner key "name", with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + + @fractional-v1 + Examples: v1 + | name | value | + | jack | spades | + | queen | clubs | + | ten | diamonds | + | nine | hearts | + | 3 | diamonds | + + @fractional-v2 + Examples: v2 + | name | value | + | jack | hearts | + | queen | spades | + | ten | clubs | + | nine | diamonds | + | 3 | clubs | + + Scenario Outline: Fractional operator shorthand + Given a String-flag with key "fractional-flag-shorthand" and a fallback value "fallback" + And a context containing a targeting key with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + + @fractional-v1 + Examples: v1 + | targeting_key | value | + | jon@company.com | heads | + | jane@company.com | tails | + + @fractional-v2 + Examples: v2 + | targeting_key | value | + | jon@company.com | heads | + | jane@company.com | tails | + + Scenario Outline: Fractional operator with shared seed + Given a String-flag with key "fractional-flag-A-shared-seed" and a fallback value "fallback" + And a context containing a nested property with outer key "user" and inner key "name", with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + + @fractional-v1 + Examples: v1 + | name | value | + | jack | hearts | + | queen | spades | + | ten | hearts | + | nine | diamonds | + + @fractional-v2 + Examples: v2 + | name | value | + | seven | hearts | + | eight | diamonds | + | nine | clubs | + | two | spades | + + Scenario Outline: Second fractional operator with shared seed + Given a String-flag with key "fractional-flag-B-shared-seed" and a fallback value "fallback" + And a context containing a nested property with outer key "user" and inner key "name", with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + + @fractional-v1 + Examples: v1 + | name | value | + | jack | ace-of-hearts | + | queen | ace-of-spades | + | ten | ace-of-hearts | + | nine | ace-of-diamonds | + + @fractional-v2 + Examples: v2 + | name | value | + | seven | ace-of-hearts | + | eight | ace-of-diamonds | + | nine | ace-of-clubs | + | two | ace-of-spades | + + # Hash edge-case vectors — keys chosen by brute-force search so their + # MurmurHash3-x86-32 (seed=0) falls at the six critical boundary values. + @fractional-v2 + Scenario Outline: Fractional operator hash edge cases + Given a String-flag with key "fractional-hash-edge-flag" and a fallback value "fallback" + And a context containing a targeting key with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + + Examples: + | key | value | + | ejOoVL | lower | + | bY9fO- | lower | + | SI7p- | lower | + | 6LvT0 | upper | + | ceQdGm | upper | + + # Nested JSON Logic expressions as bucket variant names / weights. + # Requires evaluator implementations to support the @fractional-nested feature. + # Use -t "not @fractional-nested" to exclude during transition. + + @fractional-nested + Scenario Outline: Fractional operator with nested if expression as variant name + # bucket0=[if(tier=="premium","premium","standard"),50], bucket1=["standard",50] + # jon@company.com bv(100)=36 → bucket0; user1 bv(100)=76 → bucket1 + Given an evaluator + And a String-flag with key "fractional-nested-if-flag" and a fallback value "fallback" + And a context containing a targeting key with value "" + And a context containing a key "tier", with type "String" and with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + Examples: + | targetingKey | tier | value | + | jon@company.com | premium | premium | + | jon@company.com | basic | standard | + | user1 | premium | standard | + | user1 | basic | standard | + + @fractional-nested + Scenario Outline: Fractional operator with nested var expression as variant name + # bucket0=[var("color"),50], bucket1=["blue",50] + # jon@company.com bv(100)=36 → bucket0 (resolves var "color"); user1 bv(100)=76 → bucket1 ("blue") + Given an evaluator + And a String-flag with key "fractional-nested-var-flag" and a fallback value "fallback" + And a context containing a targeting key with value "" + And a context containing a key "color", with type "String" and with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + Examples: + | targetingKey | color | value | + | jon@company.com | red | red | + | jon@company.com | green | green | + | user1 | red | blue | + | jon@company.com | yellow | fallback | + | jon@company.com | | fallback | + + @fractional-nested + Scenario Outline: Fractional operator with nested if expression as weight + # bucket0=["red",if(tier=="premium",100,0)], bucket1=["blue",10] + Given an evaluator + And a String-flag with key "fractional-nested-weight-flag" and a fallback value "fallback" + And a context containing a targeting key with value "" + And a context containing a key "tier", with type "String" and with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + Examples: + | targetingKey | tier | value | + | jon@company.com | premium | red | + | jon@company.com | basic | blue | + | user1 | premium | red | + | user1 | basic | blue | + + @fractional-nested + Scenario: Fractional as condition + Given an evaluator + And a String-flag with key "fractional-as-condition-flag" and a fallback value "zero" + And a context containing a targeting key with value "some-targeting-key" + When the flag was evaluated with details + Then the resolved details value should be "hundreds" + + @fractional-nested + Scenario: Fractional as condition evaluates false path + Given an evaluator + And a String-flag with key "fractional-as-condition-false-flag" and a fallback value "zero" + And a context containing a targeting key with value "some-targeting-key" + When the flag was evaluated with details + Then the resolved details value should be "ones" + + @operator-errors + Scenario: fractional operator with missing bucket key falls back to default variant + Given an evaluator + And a String-flag with key "fractional-null-bucket-key-flag" and a fallback value "wrong" + When the flag was evaluated with details + Then the resolved details value should be "fallback" diff --git a/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/metadata.feature b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/metadata.feature new file mode 100644 index 00000000..8f24e8db --- /dev/null +++ b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/metadata.feature @@ -0,0 +1,30 @@ +@metadata +Feature: Evaluator flag metadata + + # This test suite validates metadata handling in the Evaluator interface. + # It is associated with the flags configured in evaluator/flags/testkit-flags.json. + + Background: + Given an evaluator + + Scenario: Returns flag metadata + Given a Boolean-flag with key "metadata-flag" and a fallback value "false" + When the flag was evaluated with details + Then the resolved metadata should contain + | key | metadata_type | value | + | string | String | 1.0.2 | + | integer | Integer | 2 | + | float | Float | 0.1 | + | boolean | Boolean | true | + + Scenario Outline: Returns no metadata for flags without metadata + Given a -flag with key "" and a fallback value "" + When the flag was evaluated with details + Then the resolved metadata is empty + + Examples: + | key | flag_type | default_value | + | boolean-flag | Boolean | true | + | integer-flag | Integer | 23 | + | float-flag | Float | 2.3 | + | string-flag | String | value | diff --git a/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/no-default-variant.feature b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/no-default-variant.feature new file mode 100644 index 00000000..3844b8fe --- /dev/null +++ b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/no-default-variant.feature @@ -0,0 +1,23 @@ +@no-default-variant +Feature: Evaluator no-default-variant handling + + # Validates correct behavior when a flag's defaultVariant is null or undefined. + # In such cases the evaluator falls back to the code-default supplied by the caller. + # Flags are configured in evaluator/flags/testkit-flags.json. + + Scenario Outline: Resolve flag with no default variant + Given an evaluator + And a -flag with key "" and a fallback value "" + And a context containing a key "email", with type "String" and with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + And the reason should be "" + + Examples: + | key | type | email | code_default | resolved_value | reason | + | null-default-flag | Boolean | | true | true | DEFAULT | + | null-default-flag | Boolean | | false | false | DEFAULT | + | undefined-default-flag | Integer | | 100 | 100 | DEFAULT | + | no-default-flag-null-targeting-variant | String | wozniak@orange.com | Inventor | Inventor | DEFAULT | + | no-default-flag-null-targeting-variant | String | jobs@orange.com | CEO | CEO | TARGETING_MATCH | + | no-default-flag-undefined-targeting-variant | String | wozniak@orange.com | Retired | Retired | DEFAULT | diff --git a/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/semver.feature b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/semver.feature new file mode 100644 index 00000000..0b215c77 --- /dev/null +++ b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/semver.feature @@ -0,0 +1,45 @@ +@semver +Feature: Evaluator semantic version operator + + # Tests the sem_ver custom operator for semantic version comparisons. + + Background: + Given an evaluator + + Scenario Outline: Numeric comparison + Given a String-flag with key "equal-greater-lesser-version-flag" and a fallback value "fallback" + And a context containing a key "version", with type "String" and with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + + Examples: + | version | value | + | 2.0.0 | equal | + | 2.1.0 | greater | + | 1.9.0 | lesser | + | 2.0.0-alpha | lesser | + | 2.0.0.0 | none | + + Scenario Outline: Semantic comparison (minor/major range) + Given a String-flag with key "major-minor-version-flag" and a fallback value "fallback" + And a context containing a key "version", with type "String" and with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + + Examples: + | version | value | + | 3.0.1 | minor | + | 3.1.0 | major | + | 4.0.0 | none | + + @operator-errors + Scenario Outline: sem_ver returns null for invalid input and falls back to default variant + Given an evaluator + And a String-flag with key "" and a fallback value "wrong" + And a context containing a key "version", with type "String" and with value "" + When the flag was evaluated with details + Then the resolved details value should be "fallback" + Examples: + | key | context_value | + | semver-invalid-version-flag | not-a-version | + | semver-invalid-operator-flag | 1.0.0 | diff --git a/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/string.feature b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/string.feature new file mode 100644 index 00000000..14e995f0 --- /dev/null +++ b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/string.feature @@ -0,0 +1,18 @@ +@string +Feature: Evaluator string comparison operator + + # Tests the starts_with and ends_with custom operators. + + Scenario Outline: Substring operators + Given an evaluator + And a String-flag with key "starts-ends-flag" and a fallback value "fallback" + And a context containing a key "id", with type "String" and with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + + Examples: + | id | value | + | abcdef | prefix | + | uvwxyz | postfix | + | abcxyz | prefix | + | lmnopq | none | diff --git a/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/targeting.feature b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/targeting.feature new file mode 100644 index 00000000..3722a8a8 --- /dev/null +++ b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/targeting.feature @@ -0,0 +1,17 @@ +@targeting +Feature: Evaluator targeting + + # Tests context-based targeting rules including targeting key evaluation. + + Scenario Outline: Targeting by targeting key + Given an evaluator + And a String-flag with key "targeting-key-flag" and a fallback value "fallback" + And a context containing a targeting key with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + And the reason should be "" + + Examples: + | targeting_key | value | reason | + | 5c3d8535-f81a-4478-a6d3-afaa4d51199e | hit | TARGETING_MATCH | + | f20bd32d-703b-48b6-bc8e-79d53c85134a | miss | DEFAULT | diff --git a/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/zero-values.feature b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/zero-values.feature new file mode 100644 index 00000000..1ccfabe4 --- /dev/null +++ b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/features/zero-values.feature @@ -0,0 +1,73 @@ +Feature: Evaluator zero value resolution + + # Validates that zero/falsy/empty values are correctly resolved, + # both in static flags and when selected via targeting rules. + # Flags are configured in evaluator/flags/testkit-flags.json. + + Background: + Given an evaluator + + Scenario Outline: Resolve zero values statically + Given a -flag with key "" and a fallback value "" + When the flag was evaluated with details + Then the resolved details value should be "" + And the reason should be "STATIC" + + Examples: Boolean evaluations + | key | type | default | resolved_value | + | boolean-zero-flag | Boolean | true | false | + + Examples: String evaluations + | key | type | default | resolved_value | + | string-zero-flag | String | hi | | + + Examples: Number evaluations + | key | type | default | resolved_value | + | integer-zero-flag | Integer | 1 | 0 | + | float-zero-flag | Float | 0.1 | 0.0 | + + Examples: Object evaluations + | key | type | default | resolved_value | + | object-zero-flag | Object | {\"a\": 1} | {} | + + @targeting + Scenario Outline: Resolve zero value with targeting match + Given a -flag with key "" and a fallback value "" + And a context containing a key "email", with type "String" and with value "ballmer@macrosoft.com" + When the flag was evaluated with details + Then the resolved details value should be "" + And the reason should be "TARGETING_MATCH" + + Examples: Boolean evaluations + | key | type | default | resolved_value | + | boolean-targeted-zero-flag | Boolean | true | false | + + Examples: String evaluations + | key | type | default | resolved_value | + | string-targeted-zero-flag | String | hi | | + + Examples: Number evaluations + | key | type | default | resolved_value | + | integer-targeted-zero-flag | Integer | 1 | 0 | + | float-targeted-zero-flag | Float | 0.1 | 0.0 | + + @targeting + Scenario Outline: Resolve zero value using default when targeting does not match + Given a -flag with key "" and a fallback value "" + And a context containing a key "email", with type "String" and with value "ballmer@none.com" + When the flag was evaluated with details + Then the resolved details value should be "" + And the reason should be "DEFAULT" + + Examples: Boolean evaluations + | key | type | default | resolved_value | + | boolean-targeted-zero-flag | Boolean | true | false | + + Examples: String evaluations + | key | type | default | resolved_value | + | string-targeted-zero-flag | String | hi | | + + Examples: Number evaluations + | key | type | default | resolved_value | + | integer-targeted-zero-flag | Integer | 1 | 0 | + | float-targeted-zero-flag | Float | 0.1 | 0.0 | diff --git a/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/flag_data/testkit-flags.json b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/flag_data/testkit-flags.json new file mode 100644 index 00000000..268e905a --- /dev/null +++ b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/flag_data/testkit-flags.json @@ -0,0 +1,487 @@ +{ + "flags": { + "boolean-flag": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "on" + }, + "string-flag": { + "state": "ENABLED", + "variants": { + "greeting": "hi", + "parting": "bye" + }, + "defaultVariant": "greeting" + }, + "integer-flag": { + "state": "ENABLED", + "variants": { + "one": 1, + "ten": 10 + }, + "defaultVariant": "ten" + }, + "float-flag": { + "state": "ENABLED", + "variants": { + "tenth": 0.1, + "half": 0.5 + }, + "defaultVariant": "half" + }, + "object-flag": { + "state": "ENABLED", + "variants": { + "empty": {}, + "template": { + "showImages": true, + "title": "Check out these pics!", + "imagesPerPage": 100 + } + }, + "defaultVariant": "template" + }, + "boolean-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": false, + "non-zero": true + }, + "defaultVariant": "zero" + }, + "string-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": "", + "non-zero": "str" + }, + "defaultVariant": "zero" + }, + "integer-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": 0, + "non-zero": 1 + }, + "defaultVariant": "zero" + }, + "float-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": 0.0, + "non-zero": 1.0 + }, + "defaultVariant": "zero" + }, + "object-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": {}, + "non-zero": { + "showImages": true, + "title": "Check out these pics!", + "imagesPerPage": 100 + } + }, + "defaultVariant": "zero" + }, + "boolean-targeted-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": false, + "non-zero": true + }, + "targeting": { + "if": [{"$ref": "is_ballmer"}, "zero"] + }, + "defaultVariant": "zero" + }, + "string-targeted-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": "", + "non-zero": "str" + }, + "targeting": { + "if": [{"$ref": "is_ballmer"}, "zero"] + }, + "defaultVariant": "zero" + }, + "integer-targeted-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": 0, + "non-zero": 1 + }, + "targeting": { + "if": [{"$ref": "is_ballmer"}, "zero"] + }, + "defaultVariant": "zero" + }, + "float-targeted-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": 0.0, + "non-zero": 1.0 + }, + "targeting": { + "if": [{"$ref": "is_ballmer"}, "zero"] + }, + "defaultVariant": "zero" + }, + "wrong-flag": { + "state": "ENABLED", + "variants": { + "one": "uno", + "two": "dos" + }, + "defaultVariant": "one" + }, + "null-default-flag": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": null + }, + "undefined-default-flag": { + "state": "ENABLED", + "variants": { + "small": 10, + "big": 1000 + } + }, + "no-default-flag-null-targeting-variant": { + "state": "ENABLED", + "variants": { + "normal": "CFO", + "special": "CEO" + }, + "targeting": { + "if": [ + {"==": ["jobs@orange.com", {"var": ["email"]}]}, + "special", + null + ] + } + }, + "no-default-flag-undefined-targeting-variant": { + "state": "ENABLED", + "variants": { + "normal": "CFO", + "special": "CEO" + }, + "targeting": { + "if": [ + {"==": ["jobs@orange.com", {"var": ["email"]}]}, + "special" + ] + } + }, + "metadata-flag": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "on", + "metadata": { + "string": "1.0.2", + "integer": 2, + "boolean": true, + "float": 0.1 + } + }, + "some-email-targeted-flag": { + "state": "ENABLED", + "variants": { + "hi": "hi", + "bye": "bye", + "none": "none" + }, + "defaultVariant": "none", + "targeting": { + "if": [{"$ref": "is_ballmer"}, "hi", "bye"] + } + }, + "some-other-email-targeted-flag": { + "state": "ENABLED", + "variants": { + "yes": "yes", + "no": "no", + "none": "none" + }, + "defaultVariant": "none", + "targeting": { + "if": [{"$ref": "is_ballmer"}, "yes", "no"] + } + }, + "fractional-flag": { + "state": "ENABLED", + "variants": { + "clubs": "clubs", + "diamonds": "diamonds", + "hearts": "hearts", + "spades": "spades", + "wild": "wild" + }, + "defaultVariant": "wild", + "targeting": { + "fractional": [ + {"cat": [{"var": "$flagd.flagKey"}, {"var": "user.name"}]}, + ["clubs", 25], + ["diamonds", 25], + ["hearts", 25], + ["spades", 25] + ] + } + }, + "fractional-flag-shorthand": { + "state": "ENABLED", + "variants": { + "heads": "heads", + "tails": "tails", + "draw": "draw" + }, + "defaultVariant": "draw", + "targeting": { + "fractional": [ + ["heads"], + ["tails", 1] + ] + } + }, + "fractional-flag-A-shared-seed": { + "state": "ENABLED", + "variants": { + "clubs": "clubs", + "diamonds": "diamonds", + "hearts": "hearts", + "spades": "spades", + "wild": "wild" + }, + "defaultVariant": "wild", + "targeting": { + "fractional": [ + {"cat": ["shared-seed", {"var": "user.name"}]}, + ["clubs", 25], + ["diamonds", 25], + ["hearts", 25], + ["spades", 25] + ] + } + }, + "fractional-flag-B-shared-seed": { + "state": "ENABLED", + "variants": { + "clubs": "ace-of-clubs", + "diamonds": "ace-of-diamonds", + "hearts": "ace-of-hearts", + "spades": "ace-of-spades", + "wild": "wild" + }, + "defaultVariant": "wild", + "targeting": { + "fractional": [ + {"cat": ["shared-seed", {"var": "user.name"}]}, + ["clubs", 25], + ["diamonds", 25], + ["hearts", 25], + ["spades", 25] + ] + } + }, + "fractional-hash-edge-flag": { + "state": "ENABLED", + "variants": { "lower": "lower", "upper": "upper" }, + "defaultVariant": "lower", + "targeting": { + "fractional": [ + { "var": "targetingKey" }, + [ "lower", 50 ], + [ "upper", 50 ] + ] + } + }, + "starts-ends-flag": { + "state": "ENABLED", + "variants": { + "prefix": "prefix", + "postfix": "postfix", + "none": "none" + }, + "defaultVariant": "none", + "targeting": { + "if": [ + {"starts_with": [{"var": "id"}, "abc"]}, + "prefix", + {"if": [ + {"ends_with": [{"var": "id"}, "xyz"]}, + "postfix", + "none" + ]} + ] + } + }, + "equal-greater-lesser-version-flag": { + "state": "ENABLED", + "variants": { + "equal": "equal", + "greater": "greater", + "lesser": "lesser", + "none": "none" + }, + "defaultVariant": "none", + "targeting": { + "if": [ + {"sem_ver": [{"var": "version"}, "=", "2.0.0"]}, + "equal", + {"if": [ + {"sem_ver": [{"var": "version"}, ">", "2.0.0"]}, + "greater", + {"if": [ + {"sem_ver": [{"var": "version"}, "<", "2.0.0"]}, + "lesser", + "none" + ]} + ]} + ] + } + }, + "major-minor-version-flag": { + "state": "ENABLED", + "variants": { + "minor": "minor", + "major": "major", + "none": "none" + }, + "defaultVariant": "none", + "targeting": { + "if": [ + {"sem_ver": [{"var": "version"}, "~", "3.0.0"]}, + "minor", + {"if": [ + {"sem_ver": [{"var": "version"}, "^", "3.0.0"]}, + "major", + "none" + ]} + ] + } + }, + "targeting-key-flag": { + "state": "ENABLED", + "variants": { + "miss": "miss", + "hit": "hit" + }, + "defaultVariant": "miss", + "targeting": { + "if": [ + {"==": [{"var": "targetingKey"}, "5c3d8535-f81a-4478-a6d3-afaa4d51199e"]}, + "hit", + null + ] + } + }, + "fractional-nested-if-flag": { + "state": "ENABLED", + "variants": { "premium": "premium", "standard": "standard", "fallback": "fallback" }, + "defaultVariant": "fallback", + "targeting": { + "fractional": [ + {"var": "targetingKey"}, + [{"if": [{"==": [{"var": "tier"}, "premium"]}, "premium", "standard"]}, 50], + ["standard", 50] + ] + } + }, + "fractional-nested-var-flag": { + "state": "ENABLED", + "variants": { "red": "red", "green": "green", "blue": "blue", "fallback": "fallback" }, + "defaultVariant": "fallback", + "targeting": { + "fractional": [ + {"var": "targetingKey"}, + [{"var": "color"}, 50], + ["blue", 50] + ] + } + }, + "fractional-nested-weight-flag": { + "state": "ENABLED", + "variants": { "red": "red", "blue": "blue", "fallback": "fallback" }, + "defaultVariant": "fallback", + "targeting": { + "fractional": [ + {"var": "targetingKey"}, + ["red", {"if": [{"==": [{"var": "tier"}, "premium"]}, 100, 0]}], + ["blue", 10] + ] + } + }, + "fractional-as-condition-flag": { + "state": "ENABLED", + "variants": { "big": "hundreds", "small": "ones", "fallback": "zero" }, + "defaultVariant": "fallback", + "targeting": { + "if": [ + {"fractional": [[false, 0], [true, 100]]}, + "big", + "small" + ] + } + }, + "fractional-as-condition-false-flag": { + "state": "ENABLED", + "variants": { "big": "hundreds", "small": "ones", "fallback": "zero" }, + "defaultVariant": "fallback", + "targeting": { + "if": [ + {"fractional": [[false, 100], [true, 0]]}, + "big", + "small" + ] + } + }, + "semver-invalid-version-flag": { + "state": "ENABLED", + "variants": { "match": "match", "no-match": "no-match", "fallback": "fallback" }, + "defaultVariant": "fallback", + "targeting": { + "sem_ver": [{"var": "version"}, "=", "1.0.0"] + } + }, + "semver-invalid-operator-flag": { + "state": "ENABLED", + "variants": { "match": "match", "no-match": "no-match", "fallback": "fallback" }, + "defaultVariant": "fallback", + "targeting": { + "sem_ver": [{"var": "version"}, "===", "1.0.0"] + } + }, + "fractional-null-bucket-key-flag": { + "state": "ENABLED", + "variants": { "one": "one", "two": "two", "fallback": "fallback" }, + "defaultVariant": "fallback", + "targeting": { + "fractional": [ + {"var": "missing_key"}, + ["one", 50], + ["two", 50] + ] + } + } + }, + "$evaluators": { + "is_ballmer": { + "==": [ + "ballmer@macrosoft.com", + {"var": ["email"]} + ] + } + } +} diff --git a/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/flags.py b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/flags.py new file mode 100644 index 00000000..5557dd63 --- /dev/null +++ b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/flags.py @@ -0,0 +1,3 @@ +from openfeature.contrib.tools.flagd.testkit import load_testkit_flags + +__all__ = ["load_testkit_flags"] diff --git a/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/steps/__init__.py b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/steps/__init__.py new file mode 100644 index 00000000..326e2988 --- /dev/null +++ b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/steps/__init__.py @@ -0,0 +1,3 @@ +from openfeature.contrib.tools.flagd.testkit.steps.init_steps import * # noqa: F401, F403 +from openfeature.contrib.tools.flagd.testkit.steps.evaluation_steps import * # noqa: F401, F403 +from openfeature.contrib.tools.flagd.testkit.steps.context_steps import * # noqa: F401, F403 diff --git a/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/steps/context_steps.py b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/steps/context_steps.py new file mode 100644 index 00000000..a1e4261a --- /dev/null +++ b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/steps/context_steps.py @@ -0,0 +1,60 @@ +import typing + +import pytest +from pytest_bdd import given, parsers + +from openfeature.evaluation_context import EvaluationContext + +from ..utils import type_cast + + +@pytest.fixture +def evaluation_context() -> EvaluationContext: + return EvaluationContext() + + +@given( + parsers.cfparse( + 'a context containing a targeting key with value "{targeting_key}"' + ), +) +def assign_targeting_context(evaluation_context: EvaluationContext, targeting_key: str): + evaluation_context.targeting_key = targeting_key + + +@given( + parsers.cfparse( + 'a context containing a key "{key}", with type "{type_info}" and with value "{value}"' + ), +) +def update_context( + evaluation_context: EvaluationContext, key: str, type_info: str, value: str +): + evaluation_context.attributes[key] = type_cast[type_info](value) + + +@given( + parsers.cfparse( + 'a context containing a key "{key}", with type "{type_info}" and with value ""' + ), +) +def update_context_without_value( + evaluation_context: EvaluationContext, key: str, type_info: str +): + update_context(evaluation_context, key, type_info, "") + + +@given( + parsers.cfparse( + 'a context containing a nested property with outer key "{outer}" and inner key "{inner}", with value "{value}"' + ), +) +def update_context_nested( + evaluation_context: EvaluationContext, + outer: str, + inner: str, + value: typing.Union[str, int], +): + if outer not in evaluation_context.attributes: + evaluation_context.attributes[outer] = {} + evaluation_context.attributes[outer][inner] = value diff --git a/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/steps/evaluation_steps.py b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/steps/evaluation_steps.py new file mode 100644 index 00000000..d5cce0c8 --- /dev/null +++ b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/steps/evaluation_steps.py @@ -0,0 +1,141 @@ +import typing + +from pytest_bdd import given, parsers, then, when + +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import OpenFeatureError, TypeMismatchError +from openfeature.flag_evaluation import FlagResolutionDetails, Reason + +from ..utils import JsonPrimitive, type_cast + + +@given( + parsers.cfparse( + 'a {type_info}-flag with key "{key}" and a fallback value "{default}"' + ), + target_fixture="key_and_default_and_type", +) +def setup_key_and_default( + key: str, default: str, type_info: str +) -> tuple: + return key, default, type_info + + +@given( + parsers.cfparse( + 'a {type_info}-flag with key "{key}" and a fallback value ""' + ), + target_fixture="key_and_default_and_type", +) +def setup_key_and_empty_default( + key: str, type_info: str +) -> tuple: + return key, "", type_info + + +@when("the flag was evaluated with details", target_fixture="details") +def evaluate_with_details( + evaluator, + key_and_default_and_type: tuple, + evaluation_context: EvaluationContext, +) -> FlagResolutionDetails: + key, default, type_info = key_and_default_and_type + default_value = type_cast[type_info](default) + try: + if type_info == "Boolean": + return evaluator.resolve_boolean_value(key, default_value, evaluation_context) + elif type_info == "String": + return evaluator.resolve_string_value(key, default_value, evaluation_context) + elif type_info == "Integer": + return evaluator.resolve_integer_value(key, default_value, evaluation_context) + elif type_info == "Float": + return evaluator.resolve_float_value(key, default_value, evaluation_context) + elif type_info == "Object": + return evaluator.resolve_object_value(key, default_value, evaluation_context) + except TypeMismatchError: + return FlagResolutionDetails( + default_value, + error_code="TYPE_MISMATCH", + reason=Reason.ERROR, + ) + except OpenFeatureError as e: + return FlagResolutionDetails( + default_value, + error_code=e.error_code.value if e.error_code else None, + reason=Reason.ERROR, + ) + raise AssertionError("no valid type") + + +@then( + parsers.cfparse('the resolved details value should be ""'), +) +def resolve_details_value_empty( + details: FlagResolutionDetails, + key_and_default_and_type: tuple, +): + resolve_details_value(details, key_and_default_and_type, "") + + +@then( + parsers.cfparse('the resolved details value should be "{value}"'), +) +def resolve_details_value( + details: FlagResolutionDetails, + key_and_default_and_type: tuple, + value: str, +): + _, _, type_info = key_and_default_and_type + assert details.value == type_cast[type_info](value) + + +@then( + parsers.cfparse('the variant should be "{variant}"'), +) +def resolve_details_variant( + details: FlagResolutionDetails, + variant: str, +): + assert details.variant == variant + + +@then( + parsers.cfparse('the reason should be "{reason}"'), +) +def resolve_details_reason( + details: FlagResolutionDetails, + reason: str, +): + assert details.reason == Reason(reason) + + +@then( + parsers.cfparse('the error-code should be "{error_code}"'), +) +def resolve_details_error_code( + details: FlagResolutionDetails, + error_code: str, +): + assert details.error_code == error_code + + +@then( + parsers.cfparse('the error-code should be ""'), +) +def resolve_details_empty_error_code( + details: FlagResolutionDetails, +): + assert details.error_code is None + + +@then(parsers.cfparse("the resolved metadata should contain")) +def metadata_contains(details: FlagResolutionDetails, datatable): + assert len(details.flag_metadata) == len(datatable) - 1 # skip header row + for i in range(1, len(datatable)): + key, metadata_type, expected = datatable[i] + assert details.flag_metadata[key] == type_cast[metadata_type](expected) + + +@then("the resolved metadata is empty") +def empty_metadata(details: FlagResolutionDetails): + assert len(details.flag_metadata) == 0 diff --git a/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/steps/init_steps.py b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/steps/init_steps.py new file mode 100644 index 00000000..3fb7be9f --- /dev/null +++ b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/steps/init_steps.py @@ -0,0 +1,7 @@ +from pytest_bdd import given + + +@given("an evaluator") +def given_an_evaluator(evaluator): + """Use the evaluator fixture provided by the consumer.""" + return evaluator diff --git a/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/utils.py b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/utils.py new file mode 100644 index 00000000..557ddd1a --- /dev/null +++ b/tools/openfeature-flagd-api-testkit/src/openfeature/contrib/tools/flagd/testkit/utils.py @@ -0,0 +1,18 @@ +import json +import typing + + +def str2bool(v: str) -> bool: + return v.lower() in ("yes", "true", "t", "1") + + +type_cast: typing.Dict[str, typing.Callable] = { + "Integer": int, + "Float": float, + "String": str, + "Boolean": str2bool, + "Object": lambda v: json.loads(v.replace('\\\\"', '"')), +} + +JsonObject = typing.Union[dict, list] +JsonPrimitive = typing.Union[str, bool, float, int, JsonObject] diff --git a/tools/openfeature-flagd-api-testkit/src/scripts/scripts.py b/tools/openfeature-flagd-api-testkit/src/scripts/scripts.py new file mode 100644 index 00000000..2787d652 --- /dev/null +++ b/tools/openfeature-flagd-api-testkit/src/scripts/scripts.py @@ -0,0 +1,28 @@ +# ruff: noqa: S602, S607 +import subprocess + + +def test() -> None: + """Run pytest tests.""" + subprocess.run("pytest tests", shell=True, check=True) + + +def test_cov() -> None: + """Run tests with coverage.""" + subprocess.run("coverage run -m pytest tests", shell=True, check=True) + + +def cov_report() -> None: + """Generate coverage report.""" + subprocess.run("coverage xml", shell=True, check=True) + + +def cov() -> None: + """Run tests with coverage and generate report.""" + test_cov() + cov_report() + + +def mypy() -> None: + """Run mypy.""" + subprocess.run("mypy", shell=True, check=True) diff --git a/tools/openfeature-flagd-api-testkit/tests/__init__.py b/tools/openfeature-flagd-api-testkit/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tools/openfeature-flagd-api/CHANGELOG.md b/tools/openfeature-flagd-api/CHANGELOG.md new file mode 100644 index 00000000..825c32f0 --- /dev/null +++ b/tools/openfeature-flagd-api/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/tools/openfeature-flagd-api/LICENSE b/tools/openfeature-flagd-api/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/tools/openfeature-flagd-api/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tools/openfeature-flagd-api/README.md b/tools/openfeature-flagd-api/README.md new file mode 100644 index 00000000..8a9efcf1 --- /dev/null +++ b/tools/openfeature-flagd-api/README.md @@ -0,0 +1,23 @@ +# OpenFeature flagd Evaluator API + +This package defines the pure API/contract (Python Protocol) for the flagd evaluator, allowing others to provide their own evaluator implementations. + +It is part of the [OpenFeature Python SDK contrib](https://github.com/open-feature/python-sdk-contrib) project and mirrors the Java `flagd-api` module. + +## Installation + +```bash +pip install openfeature-flagd-api +``` + +## Usage + +```python +from openfeature.contrib.tools.flagd.api import Evaluator, FlagStoreException +``` + +Implement the `Evaluator` protocol to create a custom evaluator for the flagd provider. + +## License + +Apache 2.0 - See [LICENSE](./LICENSE) for details. diff --git a/tools/openfeature-flagd-api/pyproject.toml b/tools/openfeature-flagd-api/pyproject.toml new file mode 100644 index 00000000..db36112c --- /dev/null +++ b/tools/openfeature-flagd-api/pyproject.toml @@ -0,0 +1,59 @@ +[build-system] +requires = ["uv_build~=0.9.0"] +build-backend = "uv_build" + +[project] +name = "openfeature-flagd-api" +version = "0.1.0" +description = "OpenFeature flagd evaluator API definition" +readme = "README.md" +authors = [{ name = "OpenFeature", email = "openfeature-core@groups.io" }] +license = { file = "LICENSE" } +classifiers = [ + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", +] +keywords = [] +dependencies = [ + "openfeature-sdk>=0.8.2", +] +requires-python = ">=3.9" + +[project.urls] +Homepage = "https://github.com/open-feature/python-sdk-contrib" + +[dependency-groups] +dev = [ + "coverage[toml]>=7.10.0,<8.0.0", + "mypy>=1.18.0,<2.0.0", + "pytest>=8.4.0,<9.0.0", +] + +[tool.uv.build-backend] +module-name = "openfeature" +module-root = "src" +namespace = true + +[tool.mypy] +mypy_path = "src" +files = "src" +python_version = "3.9" +namespace_packages = true +explicit_package_bases = true +local_partial_types = true +allow_redefinition_new = true +fixed_format_cache = true +pretty = true +strict = true +disallow_any_generics = false + +[tool.coverage.run] +omit = ["tests/**"] + +[project.scripts] +cov-report = "scripts.scripts:cov_report" +cov = "scripts.scripts:cov" +mypy-check = "scripts.scripts:mypy" +test = "scripts.scripts:test" +test-cov = "scripts.scripts:test_cov" diff --git a/tools/openfeature-flagd-api/src/openfeature/contrib/tools/flagd/api/__init__.py b/tools/openfeature-flagd-api/src/openfeature/contrib/tools/flagd/api/__init__.py new file mode 100644 index 00000000..0683972c --- /dev/null +++ b/tools/openfeature-flagd-api/src/openfeature/contrib/tools/flagd/api/__init__.py @@ -0,0 +1,7 @@ +from openfeature.contrib.tools.flagd.api.evaluator import Evaluator +from openfeature.contrib.tools.flagd.api.exceptions import FlagStoreException + +__all__ = [ + "Evaluator", + "FlagStoreException", +] diff --git a/tools/openfeature-flagd-api/src/openfeature/contrib/tools/flagd/api/evaluator.py b/tools/openfeature-flagd-api/src/openfeature/contrib/tools/flagd/api/evaluator.py new file mode 100644 index 00000000..28ccae3d --- /dev/null +++ b/tools/openfeature-flagd-api/src/openfeature/contrib/tools/flagd/api/evaluator.py @@ -0,0 +1,35 @@ +import typing + +from openfeature.evaluation_context import EvaluationContext +from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType + + +class Evaluator(typing.Protocol): + def set_flags(self, flag_configuration_json: str) -> None: ... + + def set_flags_and_get_changed_keys(self, flag_configuration_json: str) -> typing.List[str]: ... + + def get_flag_set_metadata(self) -> typing.Mapping[str, typing.Union[float, int, str, bool]]: ... + + def resolve_boolean_value( + self, flag_key: str, default_value: bool, ctx: typing.Optional[EvaluationContext] = None + ) -> FlagResolutionDetails[bool]: ... + + def resolve_string_value( + self, flag_key: str, default_value: str, ctx: typing.Optional[EvaluationContext] = None + ) -> FlagResolutionDetails[str]: ... + + def resolve_integer_value( + self, flag_key: str, default_value: int, ctx: typing.Optional[EvaluationContext] = None + ) -> FlagResolutionDetails[int]: ... + + def resolve_float_value( + self, flag_key: str, default_value: float, ctx: typing.Optional[EvaluationContext] = None + ) -> FlagResolutionDetails[float]: ... + + def resolve_object_value( + self, + flag_key: str, + default_value: typing.Union[typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]], + ctx: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[typing.Union[typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]]]: ... diff --git a/tools/openfeature-flagd-api/src/openfeature/contrib/tools/flagd/api/exceptions.py b/tools/openfeature-flagd-api/src/openfeature/contrib/tools/flagd/api/exceptions.py new file mode 100644 index 00000000..f66636e6 --- /dev/null +++ b/tools/openfeature-flagd-api/src/openfeature/contrib/tools/flagd/api/exceptions.py @@ -0,0 +1,2 @@ +class FlagStoreException(Exception): + """Exception raised when flag store operations fail.""" diff --git a/tools/openfeature-flagd-api/src/scripts/scripts.py b/tools/openfeature-flagd-api/src/scripts/scripts.py new file mode 100644 index 00000000..2787d652 --- /dev/null +++ b/tools/openfeature-flagd-api/src/scripts/scripts.py @@ -0,0 +1,28 @@ +# ruff: noqa: S602, S607 +import subprocess + + +def test() -> None: + """Run pytest tests.""" + subprocess.run("pytest tests", shell=True, check=True) + + +def test_cov() -> None: + """Run tests with coverage.""" + subprocess.run("coverage run -m pytest tests", shell=True, check=True) + + +def cov_report() -> None: + """Generate coverage report.""" + subprocess.run("coverage xml", shell=True, check=True) + + +def cov() -> None: + """Run tests with coverage and generate report.""" + test_cov() + cov_report() + + +def mypy() -> None: + """Run mypy.""" + subprocess.run("mypy", shell=True, check=True) diff --git a/tools/openfeature-flagd-api/tests/__init__.py b/tools/openfeature-flagd-api/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tools/openfeature-flagd-api/tests/test_evaluator.py b/tools/openfeature-flagd-api/tests/test_evaluator.py new file mode 100644 index 00000000..fded35e2 --- /dev/null +++ b/tools/openfeature-flagd-api/tests/test_evaluator.py @@ -0,0 +1,107 @@ +import typing + +from openfeature.evaluation_context import EvaluationContext +from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType + +from openfeature.contrib.tools.flagd.api import Evaluator, FlagStoreException + + +class MockEvaluator: + """A mock implementation of the Evaluator protocol for testing.""" + + def set_flags(self, flag_configuration_json: str) -> None: + pass + + def set_flags_and_get_changed_keys(self, flag_configuration_json: str) -> typing.List[str]: + return [] + + def get_flag_set_metadata(self) -> typing.Mapping[str, typing.Union[float, int, str, bool]]: + return {} + + def resolve_boolean_value( + self, flag_key: str, default_value: bool, ctx: typing.Optional[EvaluationContext] = None + ) -> FlagResolutionDetails[bool]: + return FlagResolutionDetails(value=default_value) + + def resolve_string_value( + self, flag_key: str, default_value: str, ctx: typing.Optional[EvaluationContext] = None + ) -> FlagResolutionDetails[str]: + return FlagResolutionDetails(value=default_value) + + def resolve_integer_value( + self, flag_key: str, default_value: int, ctx: typing.Optional[EvaluationContext] = None + ) -> FlagResolutionDetails[int]: + return FlagResolutionDetails(value=default_value) + + def resolve_float_value( + self, flag_key: str, default_value: float, ctx: typing.Optional[EvaluationContext] = None + ) -> FlagResolutionDetails[float]: + return FlagResolutionDetails(value=default_value) + + def resolve_object_value( + self, + flag_key: str, + default_value: typing.Union[typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]], + ctx: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[typing.Union[typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]]]: + return FlagResolutionDetails(value=default_value) + + +def test_mock_evaluator_implements_protocol() -> None: + """Verify that MockEvaluator satisfies the Evaluator protocol.""" + evaluator: Evaluator = MockEvaluator() + assert evaluator is not None + + +def test_mock_evaluator_set_flags() -> None: + evaluator = MockEvaluator() + evaluator.set_flags('{"flags": {}}') + + +def test_mock_evaluator_set_flags_and_get_changed_keys() -> None: + evaluator = MockEvaluator() + result = evaluator.set_flags_and_get_changed_keys('{"flags": {}}') + assert result == [] + + +def test_mock_evaluator_get_flag_set_metadata() -> None: + evaluator = MockEvaluator() + result = evaluator.get_flag_set_metadata() + assert result == {} + + +def test_mock_evaluator_resolve_boolean_value() -> None: + evaluator = MockEvaluator() + result = evaluator.resolve_boolean_value("flag1", True) + assert result.value is True + + +def test_mock_evaluator_resolve_string_value() -> None: + evaluator = MockEvaluator() + result = evaluator.resolve_string_value("flag1", "default") + assert result.value == "default" + + +def test_mock_evaluator_resolve_integer_value() -> None: + evaluator = MockEvaluator() + result = evaluator.resolve_integer_value("flag1", 42) + assert result.value == 42 + + +def test_mock_evaluator_resolve_float_value() -> None: + evaluator = MockEvaluator() + result = evaluator.resolve_float_value("flag1", 3.14) + assert result.value == 3.14 + + +def test_mock_evaluator_resolve_object_value() -> None: + evaluator = MockEvaluator() + result = evaluator.resolve_object_value("flag1", {"key": "value"}) + assert result.value == {"key": "value"} + + +def test_flag_store_exception() -> None: + """Verify FlagStoreException can be raised and caught.""" + with_message = FlagStoreException("something went wrong") + assert str(with_message) == "something went wrong" + assert isinstance(with_message, Exception) diff --git a/tools/openfeature-flagd-core/CHANGELOG.md b/tools/openfeature-flagd-core/CHANGELOG.md new file mode 100644 index 00000000..e69de29b diff --git a/tools/openfeature-flagd-core/LICENSE b/tools/openfeature-flagd-core/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/tools/openfeature-flagd-core/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tools/openfeature-flagd-core/README.md b/tools/openfeature-flagd-core/README.md new file mode 100644 index 00000000..5c4e3524 --- /dev/null +++ b/tools/openfeature-flagd-core/README.md @@ -0,0 +1,25 @@ +# OpenFeature flagd Core + +Reference implementation of the flagd evaluator -- flag parsing, targeting rules (JSON logic), and custom operators (fractional, sem_ver, starts_with, ends_with). Mirrors the Java `flagd-core` module. + +It is part of the [OpenFeature Python SDK contrib](https://github.com/open-feature/python-sdk-contrib) project. + +## Installation + +```bash +pip install openfeature-flagd-core +``` + +## Usage + +```python +from openfeature.contrib.tools.flagd.core import FlagdCore + +core = FlagdCore() +core.set_flags('{"flags": {"my-flag": {"state": "ENABLED", "variants": {"on": true}, "defaultVariant": "on"}}}') +result = core.resolve_boolean_value("my-flag", False) +``` + +## License + +Apache 2.0 - See [LICENSE](./LICENSE) for details. diff --git a/tools/openfeature-flagd-core/pyproject.toml b/tools/openfeature-flagd-core/pyproject.toml new file mode 100644 index 00000000..f843c155 --- /dev/null +++ b/tools/openfeature-flagd-core/pyproject.toml @@ -0,0 +1,75 @@ +[build-system] +requires = ["uv_build~=0.9.0"] +build-backend = "uv_build" + +[project] +name = "openfeature-flagd-core" +version = "0.1.0" +description = "OpenFeature flagd evaluator core implementation" +readme = "README.md" +authors = [{ name = "OpenFeature", email = "openfeature-core@groups.io" }] +license = { file = "LICENSE" } +classifiers = [ + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", +] +keywords = [] +dependencies = [ + "openfeature-flagd-api", + "openfeature-sdk>=0.8.2", + "mmh3>=4.1.0", + "panzi-json-logic>=1.0.1", + "semver>=3,<4", +] +requires-python = ">=3.9" + +[project.urls] +Homepage = "https://github.com/open-feature/python-sdk-contrib" + +[dependency-groups] +dev = [ + "coverage[toml]>=7.10.0,<8.0.0", + "mypy>=1.18.0,<2.0.0", + "pytest>=8.4.0,<9.0.0", + "pytest-bdd>=8.1.0,<9.0.0", + "openfeature-flagd-api-testkit", +] + +[tool.uv.sources] +openfeature-flagd-api = { workspace = true } +openfeature-flagd-api-testkit = { workspace = true } + +[tool.uv.build-backend] +module-name = "openfeature" +module-root = "src" +namespace = true + +[tool.mypy] +mypy_path = "src" +files = "src" +python_version = "3.9" +namespace_packages = true +explicit_package_bases = true +local_partial_types = true +allow_redefinition_new = true +fixed_format_cache = true +pretty = true +strict = true +disallow_any_generics = false + +[[tool.mypy.overrides]] +module = [ + "json_logic.*", +] +ignore_missing_imports = true + +[tool.coverage.run] +omit = ["tests/**"] + +[project.scripts] +cov-report = "scripts.scripts:cov_report" +cov = "scripts.scripts:cov" +mypy-check = "scripts.scripts:mypy" +test = "scripts.scripts:test" +test-cov = "scripts.scripts:test_cov" diff --git a/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/__init__.py b/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/__init__.py new file mode 100644 index 00000000..4e99f245 --- /dev/null +++ b/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/__init__.py @@ -0,0 +1,3 @@ +from .flagd_core import FlagdCore + +__all__ = ["FlagdCore"] diff --git a/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/flagd_core.py b/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/flagd_core.py new file mode 100644 index 00000000..118308cf --- /dev/null +++ b/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/flagd_core.py @@ -0,0 +1,217 @@ +import json +import threading +import typing + +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import ( + ErrorCode, + FlagNotFoundError, + GeneralError, + ParseError, + TypeMismatchError, +) +from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType, Reason + +from .model.flag import Flag +from .model.flag_store import FlagStore +from .targeting import targeting + +T = typing.TypeVar("T") + +# Type map for each resolve method +_TYPE_MAP: typing.Dict[str, typing.Tuple[typing.Union[type, typing.Tuple[type, ...]], str]] = { + "boolean": ((bool,), "bool"), + "string": ((str,), "str"), + "integer": ((int,), "int"), + "float": ((int, float), "float"), + "object": ((dict, list), "dict or list"), +} + + +def _merge_metadata( + flag_metadata: typing.Optional[ + typing.Mapping[str, typing.Union[float, int, str, bool]] + ], + flag_set_metadata: typing.Optional[ + typing.Mapping[str, typing.Union[float, int, str, bool]] + ], +) -> typing.Mapping[str, typing.Union[float, int, str, bool]]: + metadata = {} if flag_set_metadata is None else dict(flag_set_metadata) + if flag_metadata is not None: + for key, value in flag_metadata.items(): + metadata[key] = value + return metadata + + +def _default_resolve( + flag: Flag, + default_value: T, + metadata: typing.Mapping[str, typing.Union[float, int, str, bool]], + reason: Reason, +) -> FlagResolutionDetails: + variant, value = flag.default + if variant is None: + return FlagResolutionDetails( + default_value, + variant=variant, + reason=Reason.DEFAULT, + flag_metadata=metadata, + ) + if variant not in flag.variants: + raise GeneralError(f"Resolved variant {variant} not in variants config.") + return FlagResolutionDetails( + value, variant=variant, flag_metadata=metadata, reason=reason + ) + + +class FlagdCore: + """Reference implementation of the Evaluator protocol for flagd.""" + + def __init__(self) -> None: + self._lock = threading.RLock() + self._flag_store = FlagStore() + + def set_flags(self, flag_configuration_json: str) -> None: + with self._lock: + data = json.loads(flag_configuration_json) + self._flag_store.update(data) + + def set_flags_and_get_changed_keys(self, flag_configuration_json: str) -> typing.List[str]: + with self._lock: + data = json.loads(flag_configuration_json) + return self._flag_store.update(data) + + def get_flag_set_metadata(self) -> typing.Mapping[str, typing.Union[float, int, str, bool]]: + with self._lock: + return dict(self._flag_store.flag_set_metadata) + + def resolve_boolean_value( + self, flag_key: str, default_value: bool, ctx: typing.Optional[EvaluationContext] = None + ) -> FlagResolutionDetails[bool]: + return self._resolve(flag_key, default_value, ctx, "boolean") + + def resolve_string_value( + self, flag_key: str, default_value: str, ctx: typing.Optional[EvaluationContext] = None + ) -> FlagResolutionDetails[str]: + return self._resolve(flag_key, default_value, ctx, "string") + + def resolve_integer_value( + self, flag_key: str, default_value: int, ctx: typing.Optional[EvaluationContext] = None + ) -> FlagResolutionDetails[int]: + return self._resolve(flag_key, default_value, ctx, "integer") + + def resolve_float_value( + self, flag_key: str, default_value: float, ctx: typing.Optional[EvaluationContext] = None + ) -> FlagResolutionDetails[float]: + result = self._resolve(flag_key, default_value, ctx, "float") + if isinstance(result.value, int): + result.value = float(result.value) + return result + + def resolve_object_value( + self, + flag_key: str, + default_value: typing.Union[ + typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType] + ], + ctx: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[ + typing.Union[typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]] + ]: + return self._resolve(flag_key, default_value, ctx, "object") + + def _resolve( + self, + key: str, + default_value: T, + evaluation_context: typing.Optional[EvaluationContext] = None, + flag_type: typing.Optional[str] = None, + ) -> FlagResolutionDetails[T]: + with self._lock: + flag = self._flag_store.get_flag(key) + if not flag: + raise FlagNotFoundError(f"Flag with key {key} not present in flag store.") + + metadata = _merge_metadata(flag.metadata, self._flag_store.flag_set_metadata) + + if flag.state == "DISABLED": + return FlagResolutionDetails( + default_value, flag_metadata=metadata, reason=Reason.DISABLED + ) + + if not flag.targeting: + result = _default_resolve(flag, default_value, metadata, Reason.STATIC) + self._check_type(result, flag_type) + return result + + try: + variant = targeting(flag.key, flag.targeting, evaluation_context) + if variant is None: + result = _default_resolve(flag, default_value, metadata, Reason.DEFAULT) + self._check_type(result, flag_type) + return result + + if isinstance(variant, bool): + variant = str(variant).lower() + elif not isinstance(variant, str): + variant = str(variant) + + if variant not in flag.variants: + raise GeneralError( + f"Resolved variant {variant} not in variants config." + ) + + except ReferenceError: + raise ParseError(f"Invalid targeting {targeting}") from ReferenceError + + variant, value = flag.get_variant(variant) + if value is None: + raise GeneralError(f"Resolved variant {variant} not in variants config.") + + result = FlagResolutionDetails( + value, + variant=variant, + reason=Reason.TARGETING_MATCH, + flag_metadata=metadata, + ) + self._check_type(result, flag_type) + return result + + @staticmethod + def _check_type( + result: FlagResolutionDetails, + flag_type: typing.Optional[str], + ) -> None: + """Validate the resolved value type matches the expected flag type.""" + if flag_type is None: + return + # Skip type check when value is the caller's default (no variant resolved) + # This happens for DEFAULT reason with no variant, or DISABLED flags + if result.reason in (Reason.DISABLED,): + return + if result.reason == Reason.DEFAULT and result.variant is None: + return + + type_info = _TYPE_MAP.get(flag_type) + if type_info is None: + return + + expected_types, type_name = type_info + value = result.value + + # For boolean type, reject int (since bool is subclass of int in Python) + if flag_type == "boolean" and isinstance(value, int) and not isinstance(value, bool): + raise TypeMismatchError( + f"Expected type {type_name} but got {type(value).__name__}" + ) + + # For integer type, reject bool (since bool is subclass of int) + if flag_type == "integer" and isinstance(value, bool): + raise TypeMismatchError( + f"Expected type {type_name} but got {type(value).__name__}" + ) + + if not isinstance(value, expected_types): + raise TypeMismatchError( + f"Expected type {type_name} but got {type(value).__name__}" + ) diff --git a/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/model/__init__.py b/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/model/__init__.py new file mode 100644 index 00000000..8c31ac25 --- /dev/null +++ b/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/model/__init__.py @@ -0,0 +1,4 @@ +from .flag import Flag +from .flag_store import FlagStore + +__all__ = ["Flag", "FlagStore"] diff --git a/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/model/flag.py b/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/model/flag.py new file mode 100644 index 00000000..e35e4e11 --- /dev/null +++ b/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/model/flag.py @@ -0,0 +1,82 @@ +import json +import re +import typing +from dataclasses import dataclass + +from openfeature.exception import ParseError + + +def _validate_metadata(key: str, value: typing.Union[float, int, str, bool]) -> None: + if key is None: + raise ParseError("Metadata key must be set") + elif not isinstance(key, str): + raise ParseError(f"Metadata key {key} must be of type str, but is {type(key)}") + elif not key: + raise ParseError("key must not be empty") + if value is None: + raise ParseError(f"Metadata value for key {key} must be set") + elif not isinstance(value, (float, int, str, bool)): + raise ParseError( + f"Metadata value {value} for key {key} must be of type float, int, str or bool, but is {type(value)}" + ) + + +@dataclass +class Flag: + key: str + state: str + variants: typing.Mapping[str, typing.Any] + default_variant: typing.Optional[typing.Union[bool, str]] = None + targeting: typing.Optional[dict] = None + metadata: typing.Optional[ + typing.Mapping[str, typing.Union[float, int, str, bool]] + ] = None + + def __post_init__(self) -> None: + if not self.state or not (self.state == "ENABLED" or self.state == "DISABLED"): + raise ParseError("Incorrect 'state' value provided in flag config") + + if not self.variants or not isinstance(self.variants, dict): + raise ParseError("Incorrect 'variants' value provided in flag config") + + if self.default_variant and not isinstance(self.default_variant, (str, bool)): + raise ParseError("Incorrect 'defaultVariant' value provided in flag config") + + if self.metadata: + if not isinstance(self.metadata, dict): + raise ParseError("Flag metadata is not a valid json object") + for key, value in self.metadata.items(): + _validate_metadata(key, value) + + @classmethod + def from_dict(cls, key: str, data: dict) -> "Flag": + if "defaultVariant" in data: + data["default_variant"] = data["defaultVariant"] + if data["default_variant"] == "": + data["default_variant"] = None + del data["defaultVariant"] + + data.pop("source", None) + data.pop("selector", None) + try: + flag = cls(key=key, **data) + return flag + except ParseError as parseError: + raise parseError + except Exception as err: + raise ParseError from err + + @property + def default(self) -> tuple[typing.Optional[str], typing.Any]: + return self.get_variant(self.default_variant) + + def get_variant( + self, variant_key: typing.Union[str, bool, None] + ) -> tuple[typing.Optional[str], typing.Any]: + if isinstance(variant_key, bool): + variant_key = str(variant_key).lower() + + if not variant_key: + return None, None + + return variant_key, self.variants.get(variant_key) diff --git a/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/model/flag_store.py b/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/model/flag_store.py new file mode 100644 index 00000000..8a308fb8 --- /dev/null +++ b/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/model/flag_store.py @@ -0,0 +1,53 @@ +import json +import re +import typing + +from openfeature.exception import ParseError + +from .flag import Flag, _validate_metadata + + +class FlagStore: + def __init__(self) -> None: + self.flags: typing.Mapping[str, Flag] = {} + self.flag_set_metadata: typing.Mapping[ + str, typing.Union[float, int, str, bool] + ] = {} + + def get_flag(self, key: str) -> typing.Optional[Flag]: + return self.flags.get(key) + + def update(self, flags_data: dict) -> typing.List[str]: + """Update flags and return list of changed flag keys.""" + flags = flags_data.get("flags", {}) + metadata = flags_data.get("metadata", {}) + evaluators: typing.Optional[dict] = flags_data.get("$evaluators") + if evaluators: + transposed = json.dumps(flags) + for name, rule in evaluators.items(): + transposed = re.sub( + rf"{{\s*\"\$ref\":\s*\"{name}\"\s*}}", json.dumps(rule), transposed + ) + flags = json.loads(transposed) + + if not isinstance(flags, dict): + raise ParseError("`flags` key of configuration must be a dictionary") + if not isinstance(metadata, dict): + raise ParseError("`metadata` key of configuration must be a dictionary") + for key, value in metadata.items(): + _validate_metadata(key, value) + + old_keys = set(self.flags.keys()) + new_flags = {key: Flag.from_dict(key, data) for key, data in flags.items()} + new_keys = set(new_flags.keys()) + + # Determine changed keys + changed_keys = list(new_keys.symmetric_difference(old_keys)) + for key in new_keys.intersection(old_keys): + if new_flags[key] != self.flags.get(key): + changed_keys.append(key) + + self.flags = new_flags + self.flag_set_metadata = metadata + + return changed_keys diff --git a/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/targeting/__init__.py b/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/targeting/__init__.py new file mode 100644 index 00000000..85643141 --- /dev/null +++ b/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/targeting/__init__.py @@ -0,0 +1,3 @@ +from .targeting import targeting + +__all__ = ["targeting"] 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 new file mode 100644 index 00000000..936c72b6 --- /dev/null +++ b/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/targeting/custom_ops.py @@ -0,0 +1,165 @@ +import logging +import typing +from dataclasses import dataclass + +import mmh3 +import semver + +JsonPrimitive = typing.Union[str, bool, float, int] +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.") + return None + + bucket_by = None + if isinstance(args[0], str): + bucket_by = args[0] + args = args[1:] + else: + seed = data.get("$flagd", {}).get("flagKey", "") + targeting_key = data.get("targetingKey") + if not targeting_key: + logger.error("No targetingKey provided for fractional shorthand syntax.") + return None + bucket_by = seed + targeting_key + + if not bucket_by: + logger.error("No hashKey value resolved") + return None + + hash_ratio = abs(mmh3.hash(bucket_by)) / (2**31 - 1) + bucket = hash_ratio * 100 + + total_weight = 0 + fractions = [] + try: + for arg in args: + fraction = _parse_fraction(arg) + if fraction: + fractions.append(fraction) + total_weight += fraction.weight + + except ValueError: + logger.debug(f"Invalid {args} configuration") + return None + + range_end: float = 0 + for fraction in fractions: + range_end += fraction.weight * 100 / total_weight + if bucket < range_end: + return fraction.variant + return None + + +def _parse_fraction(arg: JsonLogicArg) -> Fraction: + if not isinstance(arg, (tuple, list)) or not arg or len(arg) > 2: + raise ValueError( + "Fractional variant weights must be (str, int) tuple or [str] list" + ) + + if not isinstance(arg[0], str): + raise ValueError( + "Fractional variant identifier (first element) isn't of type 'str'" + ) + + if len(arg) >= 2 and not isinstance(arg[1], int): + raise ValueError( + "Fractional variant weight value (second element) isn't of type 'int'" + ) + + 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) + + return string_comp(f, data, *args) + + +def ends_with(data: dict, *args: JsonLogicArg) -> typing.Optional[bool]: + def f(s1: str, s2: str) -> bool: + return s1.endswith(s2) + + return string_comp(f, data, *args) + + +def string_comp( + comparator: typing.Callable[[str, str], bool], data: dict, *args: JsonLogicArg +) -> typing.Optional[bool]: + if not args: + logger.error("No arguments provided to string_comp operator.") + return None + if len(args) != 2: + logger.error("Exactly 2 args expected for string_comp operator.") + return None + arg1, arg2 = args + if not isinstance(arg1, str): + logger.debug(f"incorrect argument for first argument, expected string: {arg1}") + return False + if not isinstance(arg2, str): + logger.debug(f"incorrect argument for second argument, expected string: {arg2}") + return False + + return comparator(arg1, arg2) + + +def sem_ver(data: dict, *args: JsonLogicArg) -> typing.Optional[bool]: # noqa: C901 + if not args: + logger.error("No arguments provided to sem_ver operator.") + return None + if len(args) != 3: + logger.error("Exactly 3 args expected for sem_ver operator.") + return None + + arg1, op, arg2 = args + + try: + v1 = parse_version(arg1) + v2 = parse_version(arg2) + except ValueError as e: + logger.exception(e) + return None + + if op == "=": + return v1 == v2 + elif op == "!=": + return v1 != v2 + elif op == "<": + return v1 < v2 + elif op == "<=": + return v1 <= v2 + elif op == ">": + return v1 > v2 + elif op == ">=": + return v1 >= v2 + elif op == "^": + return v1.major == v2.major + elif op == "~": + return v1.major == v2.major and v1.minor == v2.minor + else: + logger.error(f"Op not supported by sem_ver: {op}") + return None + + +def parse_version(arg: typing.Any) -> semver.Version: + version = str(arg) + if version.startswith(("v", "V")): + version = version[1:] + + return semver.Version.parse(version) diff --git a/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/targeting/targeting.py b/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/targeting/targeting.py new file mode 100644 index 00000000..079a8aa1 --- /dev/null +++ b/tools/openfeature-flagd-core/src/openfeature/contrib/tools/flagd/core/targeting/targeting.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import time +import typing + +from json_logic import builtins, jsonLogic +from json_logic.types import JsonValue + +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import ParseError + +from .custom_ops import ( + ends_with, + fractional, + sem_ver, + starts_with, +) + +OPERATORS = { + **builtins.BUILTINS, + "fractional": fractional, + "starts_with": starts_with, + "ends_with": ends_with, + "sem_ver": sem_ver, +} + + +def targeting( + key: str, + targeting: dict, + evaluation_context: typing.Optional[EvaluationContext] = None, +) -> JsonValue: + if not isinstance(targeting, dict): + raise ParseError(f"Invalid 'targeting' value in flag: {targeting}") + + json_logic_context: dict[str, typing.Any] = ( + dict(evaluation_context.attributes) if evaluation_context else {} + ) + json_logic_context["$flagd"] = {"flagKey": key, "timestamp": int(time.time())} + json_logic_context["targetingKey"] = ( + evaluation_context.targeting_key if evaluation_context else None + ) + return jsonLogic(targeting, json_logic_context, OPERATORS) diff --git a/tools/openfeature-flagd-core/src/scripts/scripts.py b/tools/openfeature-flagd-core/src/scripts/scripts.py new file mode 100644 index 00000000..2787d652 --- /dev/null +++ b/tools/openfeature-flagd-core/src/scripts/scripts.py @@ -0,0 +1,28 @@ +# ruff: noqa: S602, S607 +import subprocess + + +def test() -> None: + """Run pytest tests.""" + subprocess.run("pytest tests", shell=True, check=True) + + +def test_cov() -> None: + """Run tests with coverage.""" + subprocess.run("coverage run -m pytest tests", shell=True, check=True) + + +def cov_report() -> None: + """Generate coverage report.""" + subprocess.run("coverage xml", shell=True, check=True) + + +def cov() -> None: + """Run tests with coverage and generate report.""" + test_cov() + cov_report() + + +def mypy() -> None: + """Run mypy.""" + subprocess.run("mypy", shell=True, check=True) diff --git a/tools/openfeature-flagd-core/tests/__init__.py b/tools/openfeature-flagd-core/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tools/openfeature-flagd-core/tests/e2e/__init__.py b/tools/openfeature-flagd-core/tests/e2e/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tools/openfeature-flagd-core/tests/e2e/conftest.py b/tools/openfeature-flagd-core/tests/e2e/conftest.py new file mode 100644 index 00000000..42e751cc --- /dev/null +++ b/tools/openfeature-flagd-core/tests/e2e/conftest.py @@ -0,0 +1,13 @@ +import pytest + +from openfeature.contrib.tools.flagd.testkit import load_testkit_flags +from openfeature.contrib.tools.flagd.testkit.steps import * # noqa: F401, F403 +from openfeature.contrib.tools.flagd.core import FlagdCore + + +@pytest.fixture +def evaluator(): + """Create a FlagdCore evaluator loaded with testkit flags.""" + core = FlagdCore() + core.set_flags(load_testkit_flags()) + return core diff --git a/tools/openfeature-flagd-core/tests/e2e/test_evaluator.py b/tools/openfeature-flagd-core/tests/e2e/test_evaluator.py new file mode 100644 index 00000000..bae8bc21 --- /dev/null +++ b/tools/openfeature-flagd-core/tests/e2e/test_evaluator.py @@ -0,0 +1,5 @@ +from pytest_bdd import scenarios + +from openfeature.contrib.tools.flagd.testkit import get_features_path + +scenarios(get_features_path()) diff --git a/tools/openfeature-flagd-core/tests/test_flagd_core.py b/tools/openfeature-flagd-core/tests/test_flagd_core.py new file mode 100644 index 00000000..3e8d002d --- /dev/null +++ b/tools/openfeature-flagd-core/tests/test_flagd_core.py @@ -0,0 +1,411 @@ +import json + +import pytest + +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import FlagNotFoundError, TypeMismatchError +from openfeature.flag_evaluation import Reason + +from openfeature.contrib.tools.flagd.core import FlagdCore + +TEST_FLAGS = json.dumps( + { + "flags": { + "bool-flag": { + "state": "ENABLED", + "variants": {"on": True, "off": False}, + "defaultVariant": "on", + }, + "string-flag": { + "state": "ENABLED", + "variants": {"greeting": "hi", "parting": "bye"}, + "defaultVariant": "greeting", + }, + "int-flag": { + "state": "ENABLED", + "variants": {"one": 1, "ten": 10}, + "defaultVariant": "ten", + }, + "float-flag": { + "state": "ENABLED", + "variants": {"tenth": 0.1, "half": 0.5}, + "defaultVariant": "half", + }, + "object-flag": { + "state": "ENABLED", + "variants": {"empty": {}, "full": {"key": "value"}}, + "defaultVariant": "full", + }, + "disabled-flag": { + "state": "DISABLED", + "variants": {"on": True, "off": False}, + "defaultVariant": "on", + }, + "metadata-flag": { + "state": "ENABLED", + "variants": {"on": True, "off": False}, + "defaultVariant": "on", + "metadata": {"version": "1.0"}, + }, + "wrong-flag": { + "state": "ENABLED", + "variants": {"one": "uno", "two": "dos"}, + "defaultVariant": "one", + }, + "null-default-flag": { + "state": "ENABLED", + "variants": {"on": True, "off": False}, + "defaultVariant": None, + }, + "targeted-flag": { + "state": "ENABLED", + "variants": {"hi": "hi", "bye": "bye"}, + "defaultVariant": "bye", + "targeting": { + "if": [ + {"==": [{"var": "color"}, "red"]}, + "hi", + "bye", + ] + }, + }, + }, + "metadata": {"scope": "test"}, + } +) + + +@pytest.fixture() +def core() -> FlagdCore: + c = FlagdCore() + c.set_flags(TEST_FLAGS) + return c + + +# ---- Basic resolution for all types ---- + + +class TestBooleanResolution: + def test_resolve_boolean(self, core: FlagdCore) -> None: + result = core.resolve_boolean_value("bool-flag", False) + assert result.value is True + assert result.variant == "on" + assert result.reason == Reason.STATIC + + def test_resolve_boolean_default_value_not_used(self, core: FlagdCore) -> None: + result = core.resolve_boolean_value("bool-flag", False) + assert result.value is True + + +class TestStringResolution: + def test_resolve_string(self, core: FlagdCore) -> None: + result = core.resolve_string_value("string-flag", "default") + assert result.value == "hi" + assert result.variant == "greeting" + assert result.reason == Reason.STATIC + + +class TestIntegerResolution: + def test_resolve_integer(self, core: FlagdCore) -> None: + result = core.resolve_integer_value("int-flag", 0) + assert result.value == 10 + assert result.variant == "ten" + assert result.reason == Reason.STATIC + + +class TestFloatResolution: + def test_resolve_float(self, core: FlagdCore) -> None: + result = core.resolve_float_value("float-flag", 0.0) + assert result.value == 0.5 + assert result.variant == "half" + assert result.reason == Reason.STATIC + + def test_resolve_float_converts_int(self, core: FlagdCore) -> None: + """Integer values should be converted to float for float resolution.""" + c = FlagdCore() + c.set_flags( + json.dumps( + { + "flags": { + "int-as-float": { + "state": "ENABLED", + "variants": {"val": 42}, + "defaultVariant": "val", + } + } + } + ) + ) + result = c.resolve_float_value("int-as-float", 0.0) + assert result.value == 42.0 + assert isinstance(result.value, float) + + +class TestObjectResolution: + def test_resolve_object(self, core: FlagdCore) -> None: + result = core.resolve_object_value("object-flag", {}) + assert result.value == {"key": "value"} + assert result.variant == "full" + assert result.reason == Reason.STATIC + + +# ---- set_flags and set_flags_and_get_changed_keys ---- + + +class TestSetFlags: + def test_set_flags_replaces_store(self) -> None: + core = FlagdCore() + core.set_flags(TEST_FLAGS) + result = core.resolve_boolean_value("bool-flag", False) + assert result.value is True + + # Replace with different flags + core.set_flags( + json.dumps( + { + "flags": { + "new-flag": { + "state": "ENABLED", + "variants": {"a": "alpha"}, + "defaultVariant": "a", + } + } + } + ) + ) + result = core.resolve_string_value("new-flag", "x") + assert result.value == "alpha" + + with pytest.raises(FlagNotFoundError): + core.resolve_boolean_value("bool-flag", False) + + def test_set_flags_and_get_changed_keys_returns_added(self) -> None: + core = FlagdCore() + changed = core.set_flags_and_get_changed_keys(TEST_FLAGS) + # All flags are new so they should all be in the changed list + assert "bool-flag" in changed + assert "string-flag" in changed + + def test_set_flags_and_get_changed_keys_returns_removed(self) -> None: + core = FlagdCore() + core.set_flags(TEST_FLAGS) + changed = core.set_flags_and_get_changed_keys( + json.dumps( + { + "flags": { + "bool-flag": { + "state": "ENABLED", + "variants": {"on": True, "off": False}, + "defaultVariant": "on", + } + } + } + ) + ) + # string-flag etc. were removed, so they appear as changed + assert "string-flag" in changed + + def test_set_flags_and_get_changed_keys_detects_modifications(self) -> None: + core = FlagdCore() + core.set_flags(TEST_FLAGS) + changed = core.set_flags_and_get_changed_keys( + json.dumps( + { + "flags": { + "bool-flag": { + "state": "ENABLED", + "variants": {"on": True, "off": False}, + "defaultVariant": "off", # changed default + }, + "string-flag": { + "state": "ENABLED", + "variants": {"greeting": "hi", "parting": "bye"}, + "defaultVariant": "greeting", + }, + } + } + ) + ) + assert "bool-flag" in changed + # string-flag is unchanged + assert "string-flag" not in changed + + +# ---- DISABLED flag handling ---- + + +class TestDisabledFlag: + def test_disabled_returns_default_value(self, core: FlagdCore) -> None: + result = core.resolve_boolean_value("disabled-flag", False) + assert result.value is False + assert result.reason == Reason.DISABLED + + def test_disabled_returns_caller_default(self, core: FlagdCore) -> None: + result = core.resolve_boolean_value("disabled-flag", True) + assert result.value is True + assert result.reason == Reason.DISABLED + + +# ---- FLAG_NOT_FOUND for missing flags ---- + + +class TestFlagNotFound: + def test_missing_flag_raises(self, core: FlagdCore) -> None: + with pytest.raises(FlagNotFoundError): + core.resolve_string_value("nonexistent-flag", "default") + + +# ---- Targeting rule evaluation ---- + + +class TestTargeting: + def test_targeting_match(self, core: FlagdCore) -> None: + ctx = EvaluationContext(attributes={"color": "red"}) + result = core.resolve_string_value("targeted-flag", "fallback", ctx) + assert result.value == "hi" + assert result.variant == "hi" + assert result.reason == Reason.TARGETING_MATCH + + def test_targeting_no_match_returns_other_branch(self, core: FlagdCore) -> None: + ctx = EvaluationContext(attributes={"color": "blue"}) + result = core.resolve_string_value("targeted-flag", "fallback", ctx) + assert result.value == "bye" + assert result.variant == "bye" + assert result.reason == Reason.TARGETING_MATCH + + def test_targeting_returns_none_falls_to_default(self, core: FlagdCore) -> None: + """When targeting returns None, fall back to flag's default variant.""" + c = FlagdCore() + c.set_flags( + json.dumps( + { + "flags": { + "null-targeting": { + "state": "ENABLED", + "variants": {"a": "alpha", "b": "bravo"}, + "defaultVariant": "a", + "targeting": { + "if": [ + {"==": [{"var": "x"}, "match"]}, + "b", + None, + ] + }, + } + } + } + ) + ) + ctx = EvaluationContext(attributes={"x": "no-match"}) + result = c.resolve_string_value("null-targeting", "fallback", ctx) + assert result.value == "alpha" + assert result.variant == "a" + assert result.reason == Reason.DEFAULT + + +# ---- Metadata merging ---- + + +class TestMetadata: + def test_flag_set_metadata(self, core: FlagdCore) -> None: + result = core.resolve_boolean_value("bool-flag", False) + assert result.flag_metadata is not None + assert result.flag_metadata["scope"] == "test" + + def test_flag_level_metadata_merged(self, core: FlagdCore) -> None: + result = core.resolve_boolean_value("metadata-flag", False) + assert result.flag_metadata is not None + # flag-level metadata + assert result.flag_metadata["version"] == "1.0" + # flag-set-level metadata + assert result.flag_metadata["scope"] == "test" + + def test_flag_metadata_overrides_flagset(self) -> None: + """Flag-level metadata should override flag-set-level metadata.""" + c = FlagdCore() + c.set_flags( + json.dumps( + { + "flags": { + "f": { + "state": "ENABLED", + "variants": {"on": True}, + "defaultVariant": "on", + "metadata": {"scope": "override"}, + } + }, + "metadata": {"scope": "global"}, + } + ) + ) + result = c.resolve_boolean_value("f", False) + assert result.flag_metadata["scope"] == "override" + + def test_get_flag_set_metadata(self, core: FlagdCore) -> None: + meta = core.get_flag_set_metadata() + assert meta == {"scope": "test"} + + +# ---- No-default-variant handling ---- + + +class TestNoDefaultVariant: + def test_null_default_variant_returns_caller_default(self, core: FlagdCore) -> None: + """When defaultVariant is null, return the caller's default value.""" + result = core.resolve_boolean_value("null-default-flag", True) + assert result.value is True + assert result.reason == Reason.DEFAULT + + def test_null_default_variant_returns_caller_false(self, core: FlagdCore) -> None: + result = core.resolve_boolean_value("null-default-flag", False) + assert result.value is False + assert result.reason == Reason.DEFAULT + + +# ---- Type mismatch ---- + + +class TestTypeMismatch: + def test_string_flag_resolved_as_integer_raises(self, core: FlagdCore) -> None: + """Resolving a string-valued flag as integer should raise TypeMismatchError.""" + with pytest.raises(TypeMismatchError): + core.resolve_integer_value("wrong-flag", 13) + + def test_string_flag_resolved_as_boolean_raises(self, core: FlagdCore) -> None: + with pytest.raises(TypeMismatchError): + core.resolve_boolean_value("string-flag", False) + + def test_bool_flag_resolved_as_string_raises(self, core: FlagdCore) -> None: + with pytest.raises(TypeMismatchError): + core.resolve_string_value("bool-flag", "default") + + +# ---- $evaluators support ---- + + +class TestEvaluators: + def test_evaluator_ref_expansion(self) -> None: + c = FlagdCore() + c.set_flags( + json.dumps( + { + "flags": { + "ref-flag": { + "state": "ENABLED", + "variants": {"hi": "hello", "bye": "goodbye"}, + "defaultVariant": "bye", + "targeting": { + "if": [{"$ref": "is_admin"}, "hi", "bye"] + }, + } + }, + "$evaluators": { + "is_admin": {"==": [{"var": "role"}, "admin"]} + }, + } + ) + ) + ctx = EvaluationContext(attributes={"role": "admin"}) + result = c.resolve_string_value("ref-flag", "fallback", ctx) + assert result.value == "hello" + assert result.reason == Reason.TARGETING_MATCH diff --git a/tools/openfeature-flagd-core/tests/test_targeting.py b/tools/openfeature-flagd-core/tests/test_targeting.py new file mode 100644 index 00000000..a523075b --- /dev/null +++ b/tools/openfeature-flagd-core/tests/test_targeting.py @@ -0,0 +1,180 @@ +import pytest + +from openfeature.evaluation_context import EvaluationContext + +from openfeature.contrib.tools.flagd.core.targeting import targeting +from openfeature.contrib.tools.flagd.core.targeting.custom_ops import ( + ends_with, + fractional, + sem_ver, + starts_with, +) + + +class TestTargetingFunction: + def test_simple_if_targeting(self) -> None: + rule = {"if": [{"==": [{"var": "color"}, "blue"]}, "match", "no-match"]} + ctx = EvaluationContext(attributes={"color": "blue"}) + result = targeting("test-flag", rule, ctx) + assert result == "match" + + def test_targeting_no_match(self) -> None: + rule = {"if": [{"==": [{"var": "color"}, "blue"]}, "match", "no-match"]} + ctx = EvaluationContext(attributes={"color": "red"}) + result = targeting("test-flag", rule, ctx) + assert result == "no-match" + + def test_targeting_with_targeting_key(self) -> None: + rule = { + "if": [ + {"==": [{"var": "targetingKey"}, "user-123"]}, + "hit", + "miss", + ] + } + ctx = EvaluationContext(targeting_key="user-123") + result = targeting("test-flag", rule, ctx) + assert result == "hit" + + def test_targeting_includes_flagd_context(self) -> None: + """$flagd.flagKey should be set in the context.""" + rule = { + "if": [ + {"==": [{"var": "$flagd.flagKey"}, "my-flag"]}, + "yes", + "no", + ] + } + result = targeting("my-flag", rule) + assert result == "yes" + + def test_targeting_without_context(self) -> None: + rule = {"if": [True, "a", "b"]} + result = targeting("flag", rule) + assert result == "a" + + +class TestStartsWith: + def test_starts_with_true(self) -> None: + result = starts_with({}, "hello world", "hello") + assert result is True + + def test_starts_with_false(self) -> None: + result = starts_with({}, "hello world", "world") + assert result is False + + def test_starts_with_no_args(self) -> None: + result = starts_with({}) + assert result is None + + def test_starts_with_non_string(self) -> None: + result = starts_with({}, 123, "abc") + assert result is False + + +class TestEndsWith: + def test_ends_with_true(self) -> None: + result = ends_with({}, "hello world", "world") + assert result is True + + def test_ends_with_false(self) -> None: + result = ends_with({}, "hello world", "hello") + assert result is False + + +class TestSemVer: + def test_equal(self) -> None: + assert sem_ver({}, "2.0.0", "=", "2.0.0") is True + + def test_not_equal(self) -> None: + assert sem_ver({}, "2.0.0", "!=", "1.0.0") is True + + def test_less_than(self) -> None: + assert sem_ver({}, "1.0.0", "<", "2.0.0") is True + + def test_greater_than(self) -> None: + assert sem_ver({}, "3.0.0", ">", "2.0.0") is True + + def test_less_equal(self) -> None: + assert sem_ver({}, "2.0.0", "<=", "2.0.0") is True + + def test_greater_equal(self) -> None: + assert sem_ver({}, "2.0.0", ">=", "2.0.0") is True + + def test_major_match(self) -> None: + assert sem_ver({}, "3.1.0", "^", "3.0.0") is True + + def test_major_no_match(self) -> None: + assert sem_ver({}, "4.0.0", "^", "3.0.0") is False + + def test_minor_match(self) -> None: + assert sem_ver({}, "3.0.1", "~", "3.0.0") is True + + def test_minor_no_match(self) -> None: + assert sem_ver({}, "3.1.0", "~", "3.0.0") is False + + def test_v_prefix(self) -> None: + assert sem_ver({}, "v2.0.0", "=", "2.0.0") is True + + def test_invalid_version(self) -> None: + result = sem_ver({}, "not-a-version", "=", "1.0.0") + assert result is None + + def test_invalid_operator(self) -> None: + result = sem_ver({}, "1.0.0", "===", "1.0.0") + assert result is None + + def test_no_args(self) -> None: + result = sem_ver({}) + assert result is None + + def test_wrong_arg_count(self) -> None: + result = sem_ver({}, "1.0.0", "=") + assert result is None + + +class TestFractional: + def test_fractional_with_explicit_key(self) -> None: + """Fractional with an explicit bucket key should return a variant.""" + result = fractional( + {}, + "test-key", + ["a", 50], + ["b", 50], + ) + assert result in ("a", "b") + + def test_fractional_with_targeting_key(self) -> None: + """Fractional shorthand uses targetingKey + flagKey as seed.""" + data = { + "targetingKey": "user-1", + "$flagd": {"flagKey": "my-flag"}, + } + result = fractional( + data, + ["heads", 50], + ["tails", 50], + ) + assert result in ("heads", "tails") + + def test_fractional_no_targeting_key(self) -> None: + """Fractional without targetingKey returns None.""" + data = {"$flagd": {"flagKey": "my-flag"}} + result = fractional( + data, + ["a", 50], + ["b", 50], + ) + assert result is None + + def test_fractional_no_args(self) -> None: + result = fractional({}) + assert result is None + + def test_fractional_deterministic(self) -> None: + """Same input should always produce same output.""" + results = set() + for _ in range(10): + r = fractional({}, "stable-key", ["x", 50], ["y", 50]) + results.add(r) + assert len(results) == 1 diff --git a/uv.lock b/uv.lock index 894b2123..9335f103 100644 --- a/uv.lock +++ b/uv.lock @@ -9,6 +9,9 @@ resolution-markers = [ [manifest] members = [ + "openfeature-flagd-api", + "openfeature-flagd-api-testkit", + "openfeature-flagd-core", "openfeature-hooks-opentelemetry", "openfeature-provider-env-var", "openfeature-provider-flagd", @@ -1069,6 +1072,106 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "openfeature-flagd-api" +version = "0.1.0" +source = { editable = "tools/openfeature-flagd-api" } +dependencies = [ + { name = "openfeature-sdk" }, +] + +[package.dev-dependencies] +dev = [ + { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, + { name = "coverage", version = "7.11.0", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, + { name = "mypy" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [{ name = "openfeature-sdk", specifier = ">=0.8.2" }] + +[package.metadata.requires-dev] +dev = [ + { name = "coverage", extras = ["toml"], specifier = ">=7.10.0,<8.0.0" }, + { name = "mypy", specifier = ">=1.18.0,<2.0.0" }, + { name = "pytest", specifier = ">=8.4.0,<9.0.0" }, +] + +[[package]] +name = "openfeature-flagd-api-testkit" +version = "0.1.0" +source = { editable = "tools/openfeature-flagd-api-testkit" } +dependencies = [ + { name = "openfeature-flagd-api" }, + { name = "openfeature-sdk" }, + { name = "pytest" }, + { name = "pytest-bdd" }, +] + +[package.dev-dependencies] +dev = [ + { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, + { name = "coverage", version = "7.11.0", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, + { name = "mypy" }, + { name = "openfeature-flagd-core" }, +] + +[package.metadata] +requires-dist = [ + { name = "openfeature-flagd-api", editable = "tools/openfeature-flagd-api" }, + { name = "openfeature-sdk", specifier = ">=0.8.2" }, + { name = "pytest", specifier = ">=8.4.0" }, + { name = "pytest-bdd", specifier = ">=8.1.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "coverage", extras = ["toml"], specifier = ">=7.10.0,<8.0.0" }, + { name = "mypy", specifier = ">=1.18.0,<2.0.0" }, + { name = "openfeature-flagd-core", editable = "tools/openfeature-flagd-core" }, +] + +[[package]] +name = "openfeature-flagd-core" +version = "0.1.0" +source = { editable = "tools/openfeature-flagd-core" } +dependencies = [ + { name = "mmh3" }, + { name = "openfeature-flagd-api" }, + { name = "openfeature-sdk" }, + { name = "panzi-json-logic" }, + { name = "semver" }, +] + +[package.dev-dependencies] +dev = [ + { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, + { name = "coverage", version = "7.11.0", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, + { name = "mypy" }, + { name = "openfeature-flagd-api-testkit" }, + { name = "pytest" }, + { name = "pytest-bdd" }, +] + +[package.metadata] +requires-dist = [ + { name = "mmh3", specifier = ">=4.1.0" }, + { name = "openfeature-flagd-api", editable = "tools/openfeature-flagd-api" }, + { name = "openfeature-sdk", specifier = ">=0.8.2" }, + { name = "panzi-json-logic", specifier = ">=1.0.1" }, + { name = "semver", specifier = ">=3,<4" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "coverage", extras = ["toml"], specifier = ">=7.10.0,<8.0.0" }, + { name = "mypy", specifier = ">=1.18.0,<2.0.0" }, + { name = "openfeature-flagd-api-testkit", editable = "tools/openfeature-flagd-api-testkit" }, + { name = "pytest", specifier = ">=8.4.0,<9.0.0" }, + { name = "pytest-bdd", specifier = ">=8.1.0,<9.0.0" }, +] + [[package]] name = "openfeature-hooks-opentelemetry" version = "0.2.0" @@ -1133,12 +1236,10 @@ dependencies = [ { name = "cachebox", version = "5.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "cachebox", version = "5.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "grpcio" }, - { name = "mmh3" }, + { name = "openfeature-flagd-core" }, { name = "openfeature-sdk" }, - { name = "panzi-json-logic" }, { name = "protobuf" }, { name = "pyyaml" }, - { name = "semver" }, ] [package.dev-dependencies] @@ -1162,12 +1263,10 @@ requires-dist = [ { name = "cachebox", marker = "python_full_version < '3.10'", specifier = "<=5.0.1" }, { name = "cachebox", marker = "python_full_version >= '3.10'" }, { name = "grpcio", specifier = ">=1.68.1" }, - { name = "mmh3", specifier = ">=4.1.0" }, + { name = "openfeature-flagd-core", editable = "tools/openfeature-flagd-core" }, { name = "openfeature-sdk", specifier = ">=0.8.2" }, - { name = "panzi-json-logic", specifier = ">=1.0.1" }, { name = "protobuf", specifier = ">=5.26.1" }, { name = "pyyaml", specifier = ">=6.0.1" }, - { name = "semver", specifier = ">=3,<4" }, ] [package.metadata.requires-dev] @@ -1257,6 +1356,9 @@ name = "openfeature-python-contrib" version = "0.0.0" source = { virtual = "." } dependencies = [ + { name = "openfeature-flagd-api" }, + { name = "openfeature-flagd-api-testkit" }, + { name = "openfeature-flagd-core" }, { name = "openfeature-hooks-opentelemetry" }, { name = "openfeature-provider-env-var" }, { name = "openfeature-provider-flagd" }, @@ -1274,6 +1376,9 @@ dev = [ [package.metadata] requires-dist = [ + { name = "openfeature-flagd-api", editable = "tools/openfeature-flagd-api" }, + { name = "openfeature-flagd-api-testkit", editable = "tools/openfeature-flagd-api-testkit" }, + { name = "openfeature-flagd-core", editable = "tools/openfeature-flagd-core" }, { name = "openfeature-hooks-opentelemetry", editable = "hooks/openfeature-hooks-opentelemetry" }, { name = "openfeature-provider-env-var", editable = "providers/openfeature-provider-env-var" }, { name = "openfeature-provider-flagd", editable = "providers/openfeature-provider-flagd" },