diff --git a/.gitmodules b/.gitmodules index 0a552e2f..a8bef85f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "schemas"] path = providers/openfeature-provider-flagd/schemas url = https://github.com/open-feature/schemas +[submodule "providers/openfeature-provider-flagd/test-harness"] + path = providers/openfeature-provider-flagd/test-harness + url = git@github.com:open-feature/flagd-testbed.git diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 508d68c5..ac517000 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,8 +19,13 @@ repos: rev: v1.9.0 hooks: - id: mypy + args: [--python-version=3.8] additional_dependencies: - openfeature-sdk>=0.4.0 - opentelemetry-api - types-protobuf + - types-PyYAML + - mmh3 + - semver + - panzi-json-logic exclude: proto|tests diff --git a/providers/openfeature-provider-flagd/README.md b/providers/openfeature-provider-flagd/README.md index ea2a533c..aa63c55c 100644 --- a/providers/openfeature-provider-flagd/README.md +++ b/providers/openfeature-provider-flagd/README.md @@ -19,6 +19,19 @@ from openfeature.contrib.provider.flagd import FlagdProvider api.set_provider(FlagdProvider()) ``` +To use in-process evaluation in offline mode with a file as source: + +```python +from openfeature import api +from openfeature.contrib.provider.flagd import FlagdProvider +from openfeature.contrib.provider.flagd.config import ResolverType + +api.set_provider(FlagdProvider( + resolver_type=ResolverType.IN_PROCESS, + offline_flag_source_path="my-flag.json", +)) +``` + ### Configuration options The default options can be defined in the FlagdProvider constructor. diff --git a/providers/openfeature-provider-flagd/pyproject.toml b/providers/openfeature-provider-flagd/pyproject.toml index cd435bc7..6fe406b6 100644 --- a/providers/openfeature-provider-flagd/pyproject.toml +++ b/providers/openfeature-provider-flagd/pyproject.toml @@ -20,6 +20,10 @@ dependencies = [ "openfeature-sdk>=0.4.0", "grpcio>=1.60.0", "protobuf>=4.25.2", + "mmh3>=4.1.0", + "panzi-json-logic>=1.0.1", + "semver>=3,<4", + "pyyaml>=6.0.1", ] requires-python = ">=3.8" @@ -32,6 +36,7 @@ Homepage = "https://github.com/open-feature/python-sdk-contrib" dependencies = [ "coverage[toml]>=6.5", "pytest", + "pytest-bdd", ] post-install-commands = [ "./scripts/gen_protos.sh" @@ -42,6 +47,7 @@ test = "pytest {args:tests}" test-cov = "coverage run -m pytest {args:tests}" cov-report = [ "coverage xml", + "coverage html", ] cov = [ "test-cov", @@ -61,4 +67,5 @@ packages = ["src/openfeature"] omit = [ # exclude generated files "src/openfeature/contrib/provider/flagd/proto/*", + "tests/**", ] diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/config.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/config.py index e2db98a5..a95c3153 100644 --- a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/config.py +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/config.py @@ -1,5 +1,6 @@ import os import typing +from enum import Enum T = typing.TypeVar("T") @@ -17,13 +18,21 @@ def env_or_default( return val if cast is None else cast(val) +class ResolverType(Enum): + GRPC = "grpc" + IN_PROCESS = "in-process" + + class Config: - def __init__( + def __init__( # noqa: PLR0913 self, host: typing.Optional[str] = None, port: typing.Optional[int] = None, tls: typing.Optional[bool] = None, timeout: typing.Optional[int] = None, + resolver_type: typing.Optional[ResolverType] = None, + offline_flag_source_path: typing.Optional[str] = None, + offline_poll_interval_seconds: typing.Optional[float] = None, ): self.host = env_or_default("FLAGD_HOST", "localhost") if host is None else host self.port = ( @@ -33,3 +42,18 @@ def __init__( env_or_default("FLAGD_TLS", False, cast=str_to_bool) if tls is None else tls ) self.timeout = 5 if timeout is None else timeout + self.resolver_type = ( + ResolverType(env_or_default("FLAGD_RESOLVER_TYPE", "grpc")) + if resolver_type is None + else resolver_type + ) + self.offline_flag_source_path = ( + env_or_default("FLAGD_OFFLINE_FLAG_SOURCE_PATH", None) + if offline_flag_source_path is None + else offline_flag_source_path + ) + self.offline_poll_interval_seconds = ( + float(env_or_default("FLAGD_OFFLINE_POLL_INTERVAL_SECONDS", 1.0)) + if offline_poll_interval_seconds is None + else offline_poll_interval_seconds + ) diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/provider.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/provider.py index ea91a1d1..76307475 100644 --- a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/provider.py +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/provider.py @@ -23,24 +23,13 @@ import typing -import grpc -from google.protobuf.struct_pb2 import Struct - from openfeature.evaluation_context import EvaluationContext -from openfeature.exception import ( - FlagNotFoundError, - GeneralError, - InvalidContextError, - ParseError, - TypeMismatchError, -) from openfeature.flag_evaluation import FlagResolutionDetails from openfeature.provider.metadata import Metadata from openfeature.provider.provider import AbstractProvider -from .config import Config -from .flag_type import FlagType -from .proto.schema.v1 import schema_pb2, schema_pb2_grpc +from .config import Config, ResolverType +from .resolvers import AbstractResolver, GrpcResolver, InProcessResolver T = typing.TypeVar("T") @@ -48,12 +37,15 @@ class FlagdProvider(AbstractProvider): """Flagd OpenFeature Provider""" - def __init__( + def __init__( # noqa: PLR0913 self, host: typing.Optional[str] = None, port: typing.Optional[int] = None, tls: typing.Optional[bool] = None, timeout: typing.Optional[int] = None, + resolver_type: typing.Optional[ResolverType] = None, + offline_flag_source_path: typing.Optional[str] = None, + offline_poll_interval_seconds: typing.Optional[float] = None, ): """ Create an instance of the FlagdProvider @@ -68,14 +60,26 @@ def __init__( port=port, tls=tls, timeout=timeout, + resolver_type=resolver_type, + offline_flag_source_path=offline_flag_source_path, + offline_poll_interval_seconds=offline_poll_interval_seconds, ) - channel_factory = grpc.secure_channel if tls else grpc.insecure_channel - self.channel = channel_factory(f"{self.config.host}:{self.config.port}") - self.stub = schema_pb2_grpc.ServiceStub(self.channel) + self.resolver = self.setup_resolver() + + def setup_resolver(self) -> AbstractResolver: + if self.config.resolver_type == ResolverType.GRPC: + return GrpcResolver(self.config) + elif self.config.resolver_type == ResolverType.IN_PROCESS: + return InProcessResolver(self.config, self) + else: + raise ValueError( + f"`resolver_type` parameter invalid: {self.config.resolver_type}" + ) def shutdown(self) -> None: - self.channel.close() + if self.resolver: + self.resolver.shutdown() def get_metadata(self) -> Metadata: """Returns provider metadata""" @@ -87,7 +91,9 @@ def resolve_boolean_details( default_value: bool, evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[bool]: - return self._resolve(key, FlagType.BOOLEAN, default_value, evaluation_context) + return self.resolver.resolve_boolean_details( + key, default_value, evaluation_context + ) def resolve_string_details( self, @@ -95,7 +101,9 @@ def resolve_string_details( default_value: str, evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[str]: - return self._resolve(key, FlagType.STRING, default_value, evaluation_context) + return self.resolver.resolve_string_details( + key, default_value, evaluation_context + ) def resolve_float_details( self, @@ -103,7 +111,9 @@ def resolve_float_details( default_value: float, evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[float]: - return self._resolve(key, FlagType.FLOAT, default_value, evaluation_context) + return self.resolver.resolve_float_details( + key, default_value, evaluation_context + ) def resolve_integer_details( self, @@ -111,7 +121,9 @@ def resolve_integer_details( default_value: int, evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[int]: - return self._resolve(key, FlagType.INTEGER, default_value, evaluation_context) + return self.resolver.resolve_integer_details( + key, default_value, evaluation_context + ) def resolve_object_details( self, @@ -119,76 +131,6 @@ def resolve_object_details( default_value: typing.Union[dict, list], evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[typing.Union[dict, list]]: - return self._resolve(key, FlagType.OBJECT, default_value, evaluation_context) - - def _resolve( - self, - flag_key: str, - flag_type: FlagType, - default_value: T, - evaluation_context: typing.Optional[EvaluationContext], - ) -> FlagResolutionDetails[T]: - context = self._convert_context(evaluation_context) - call_args = {"timeout": self.config.timeout} - try: - if flag_type == FlagType.BOOLEAN: - request = schema_pb2.ResolveBooleanRequest( # type:ignore[attr-defined] - flag_key=flag_key, context=context - ) - response = self.stub.ResolveBoolean(request, **call_args) - elif flag_type == FlagType.STRING: - request = schema_pb2.ResolveStringRequest( # type:ignore[attr-defined] - flag_key=flag_key, context=context - ) - response = self.stub.ResolveString(request, **call_args) - elif flag_type == FlagType.OBJECT: - request = schema_pb2.ResolveObjectRequest( # type:ignore[attr-defined] - flag_key=flag_key, context=context - ) - response = self.stub.ResolveObject(request, **call_args) - elif flag_type == FlagType.FLOAT: - request = schema_pb2.ResolveFloatRequest( # type:ignore[attr-defined] - flag_key=flag_key, context=context - ) - response = self.stub.ResolveFloat(request, **call_args) - elif flag_type == FlagType.INTEGER: - request = schema_pb2.ResolveIntRequest( # type:ignore[attr-defined] - flag_key=flag_key, context=context - ) - response = self.stub.ResolveInt(request, **call_args) - else: - raise ValueError(f"Unknown flag type: {flag_type}") - - except grpc.RpcError as e: - code = e.code() - message = f"received grpc status code {code}" - - if code == grpc.StatusCode.NOT_FOUND: - raise FlagNotFoundError(message) from e - elif code == grpc.StatusCode.INVALID_ARGUMENT: - raise TypeMismatchError(message) from e - elif code == grpc.StatusCode.DATA_LOSS: - raise ParseError(message) from e - raise GeneralError(message) from e - - # Got a valid flag and valid type. Return it. - return FlagResolutionDetails( - value=response.value, - reason=response.reason, - variant=response.variant, + return self.resolver.resolve_object_details( + key, default_value, evaluation_context ) - - def _convert_context( - self, evaluation_context: typing.Optional[EvaluationContext] - ) -> Struct: - s = Struct() - if evaluation_context: - try: - s["targetingKey"] = evaluation_context.targeting_key - s.update(evaluation_context.attributes) - except ValueError as exc: - message = ( - "could not serialize evaluation context to google.protobuf.Struct" - ) - raise InvalidContextError(message) from exc - return s diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/__init__.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/__init__.py new file mode 100644 index 00000000..53e17938 --- /dev/null +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/__init__.py @@ -0,0 +1,51 @@ +import typing + +from typing_extensions import Protocol + +from openfeature.evaluation_context import EvaluationContext +from openfeature.flag_evaluation import FlagResolutionDetails + +from .grpc import GrpcResolver +from .in_process import InProcessResolver + + +class AbstractResolver(Protocol): + def shutdown(self) -> None: ... + + def resolve_boolean_details( + self, + key: str, + default_value: bool, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[bool]: ... + + def resolve_string_details( + self, + key: str, + default_value: str, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[str]: ... + + def resolve_float_details( + self, + key: str, + default_value: float, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[float]: ... + + def resolve_integer_details( + self, + key: str, + default_value: int, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[int]: ... + + def resolve_object_details( + self, + key: str, + default_value: typing.Union[dict, list], + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[typing.Union[dict, list]]: ... + + +__all__ = ["AbstractResolver", "GrpcResolver", "InProcessResolver"] diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py new file mode 100644 index 00000000..caab101a --- /dev/null +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py @@ -0,0 +1,145 @@ +import typing + +import grpc +from google.protobuf.struct_pb2 import Struct + +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import ( + FlagNotFoundError, + GeneralError, + InvalidContextError, + ParseError, + TypeMismatchError, +) +from openfeature.flag_evaluation import FlagResolutionDetails + +from ..config import Config +from ..flag_type import FlagType +from ..proto.schema.v1 import schema_pb2, schema_pb2_grpc + +T = typing.TypeVar("T") + + +class GrpcResolver: + def __init__(self, config: Config): + self.config = config + channel_factory = ( + grpc.secure_channel if self.config.tls else grpc.insecure_channel + ) + self.channel = channel_factory(f"{self.config.host}:{self.config.port}") + self.stub = schema_pb2_grpc.ServiceStub(self.channel) + + def shutdown(self) -> None: + self.channel.close() + + def resolve_boolean_details( + self, + key: str, + default_value: bool, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[bool]: + return self._resolve(key, FlagType.BOOLEAN, default_value, evaluation_context) + + def resolve_string_details( + self, + key: str, + default_value: str, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[str]: + return self._resolve(key, FlagType.STRING, default_value, evaluation_context) + + def resolve_float_details( + self, + key: str, + default_value: float, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[float]: + return self._resolve(key, FlagType.FLOAT, default_value, evaluation_context) + + def resolve_integer_details( + self, + key: str, + default_value: int, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[int]: + return self._resolve(key, FlagType.INTEGER, default_value, evaluation_context) + + def resolve_object_details( + self, + key: str, + default_value: typing.Union[dict, list], + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[typing.Union[dict, list]]: + return self._resolve(key, FlagType.OBJECT, default_value, evaluation_context) + + def _resolve( + self, + flag_key: str, + flag_type: FlagType, + default_value: T, + evaluation_context: typing.Optional[EvaluationContext], + ) -> FlagResolutionDetails[T]: + context = self._convert_context(evaluation_context) + call_args = {"timeout": self.config.timeout} + try: + if flag_type == FlagType.BOOLEAN: + request = schema_pb2.ResolveBooleanRequest( # type:ignore[attr-defined] + flag_key=flag_key, context=context + ) + response = self.stub.ResolveBoolean(request, **call_args) + elif flag_type == FlagType.STRING: + request = schema_pb2.ResolveStringRequest( # type:ignore[attr-defined] + flag_key=flag_key, context=context + ) + response = self.stub.ResolveString(request, **call_args) + elif flag_type == FlagType.OBJECT: + request = schema_pb2.ResolveObjectRequest( # type:ignore[attr-defined] + flag_key=flag_key, context=context + ) + response = self.stub.ResolveObject(request, **call_args) + elif flag_type == FlagType.FLOAT: + request = schema_pb2.ResolveFloatRequest( # type:ignore[attr-defined] + flag_key=flag_key, context=context + ) + response = self.stub.ResolveFloat(request, **call_args) + elif flag_type == FlagType.INTEGER: + request = schema_pb2.ResolveIntRequest( # type:ignore[attr-defined] + flag_key=flag_key, context=context + ) + response = self.stub.ResolveInt(request, **call_args) + else: + raise ValueError(f"Unknown flag type: {flag_type}") + + except grpc.RpcError as e: + code = e.code() + message = f"received grpc status code {code}" + + if code == grpc.StatusCode.NOT_FOUND: + raise FlagNotFoundError(message) from e + elif code == grpc.StatusCode.INVALID_ARGUMENT: + raise TypeMismatchError(message) from e + elif code == grpc.StatusCode.DATA_LOSS: + raise ParseError(message) from e + raise GeneralError(message) from e + + # Got a valid flag and valid type. Return it. + return FlagResolutionDetails( + value=response.value, + reason=response.reason, + variant=response.variant, + ) + + def _convert_context( + self, evaluation_context: typing.Optional[EvaluationContext] + ) -> Struct: + s = Struct() + if evaluation_context: + try: + s["targetingKey"] = evaluation_context.targeting_key + s.update(evaluation_context.attributes) + except ValueError as exc: + message = ( + "could not serialize evaluation context to google.protobuf.Struct" + ) + raise InvalidContextError(message) from exc + return s 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 new file mode 100644 index 00000000..907a62d6 --- /dev/null +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/in_process.py @@ -0,0 +1,122 @@ +import time +import typing + +from json_logic import builtins, jsonLogic # type: ignore[import-untyped] + +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import FlagNotFoundError, ParseError +from openfeature.flag_evaluation import FlagResolutionDetails, Reason +from openfeature.provider.provider import AbstractProvider + +from ..config import Config +from .process.custom_ops import ends_with, fractional, sem_ver, starts_with +from .process.file_watcher import FileWatcherFlagStore + +T = typing.TypeVar("T") + + +class InProcessResolver: + OPERATORS: typing.ClassVar[dict] = { + **builtins.BUILTINS, + "fractional": fractional, + "starts_with": starts_with, + "ends_with": ends_with, + "sem_ver": sem_ver, + } + + def __init__(self, config: Config, provider: AbstractProvider): + self.config = config + self.provider = provider + if not self.config.offline_flag_source_path: + raise ValueError( + "offline_flag_source_path must be provided when using in-process resolver" + ) + self.flag_store = FileWatcherFlagStore( + self.config.offline_flag_source_path, + self.provider, + self.config.offline_poll_interval_seconds, + ) + + def shutdown(self) -> None: + self.flag_store.shutdown() + + def resolve_boolean_details( + self, + key: str, + default_value: bool, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[bool]: + return self._resolve(key, default_value, evaluation_context) + + def resolve_string_details( + self, + key: str, + default_value: str, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[str]: + return self._resolve(key, default_value, evaluation_context) + + def resolve_float_details( + self, + key: str, + default_value: float, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[float]: + return self._resolve(key, default_value, evaluation_context) + + def resolve_integer_details( + self, + key: str, + default_value: int, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[int]: + return self._resolve(key, default_value, evaluation_context) + + def resolve_object_details( + self, + key: str, + default_value: typing.Union[dict, list], + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[typing.Union[dict, list]]: + 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.") + + if flag.state == "DISABLED": + return FlagResolutionDetails(default_value, reason=Reason.DISABLED) + + if not flag.targeting: + variant, value = flag.default + return FlagResolutionDetails(value, variant=variant, reason=Reason.STATIC) + + json_logic_context = 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 + ) + variant = jsonLogic(flag.targeting, json_logic_context, self.OPERATORS) + if variant is None: + variant, value = flag.default + return FlagResolutionDetails(value, variant=variant, reason=Reason.DEFAULT) + if not isinstance(variant, (str, bool)): + raise ParseError( + "Parsed JSONLogic targeting did not return a string or bool" + ) + + variant, value = flag.get_variant(variant) + if not value: + raise ParseError(f"Resolved variant {variant} not in variants config.") + + return FlagResolutionDetails( + value, + variant=variant, + reason=Reason.TARGETING_MATCH, + ) 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 new file mode 100644 index 00000000..17763615 --- /dev/null +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/custom_ops.py @@ -0,0 +1,126 @@ +import logging +import typing + +import mmh3 +import semver + +JsonPrimitive = typing.Union[str, bool, float, int] +JsonLogicArg = typing.Union[JsonPrimitive, typing.Sequence[JsonPrimitive]] + +logger = logging.getLogger("openfeature.contrib") + + +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 = int(hash_ratio * 100) + + for arg in args: + if ( + not isinstance(arg, (tuple, list)) + or len(arg) != 2 + or not isinstance(arg[0], str) + or not isinstance(arg[1], int) + ): + logger.error("Fractional variant weights must be (str, int) tuple") + return None + variant_weights: typing.Tuple[typing.Tuple[str, int]] = args # type: ignore[assignment] + + range_end = 0 + for variant, weight in variant_weights: + range_end += weight + if bucket < range_end: + return variant + + return None + + +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 = semver.Version.parse(str(arg1)) + v2 = semver.Version.parse(str(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 diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/file_watcher.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/file_watcher.py new file mode 100644 index 00000000..0918981f --- /dev/null +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/file_watcher.py @@ -0,0 +1,89 @@ +import json +import logging +import os +import re +import threading +import time +import typing + +import yaml + +from openfeature.event import ProviderEventDetails +from openfeature.exception import ParseError +from openfeature.provider.provider import AbstractProvider + +from .flags import Flag + +logger = logging.getLogger("openfeature.contrib") + + +class FileWatcherFlagStore: + def __init__( + self, + file_path: str, + provider: AbstractProvider, + poll_interval_seconds: float = 1.0, + ): + self.file_path = file_path + self.provider = provider + self.poll_interval_seconds = poll_interval_seconds + + self.last_modified = 0.0 + self.flag_data: typing.Mapping[str, Flag] = {} + self.load_data() + self.thread = threading.Thread(target=self.refresh_file, daemon=True) + self.thread.start() + + def shutdown(self) -> None: + pass + + def get_flag(self, key: str) -> typing.Optional[Flag]: + return self.flag_data.get(key) + + def refresh_file(self) -> None: + while True: + time.sleep(self.poll_interval_seconds) + logger.debug("checking for new flag store contents from file") + last_modified = os.path.getmtime(self.file_path) + if last_modified > self.last_modified: + self.load_data(last_modified) + + def load_data(self, modified_time: typing.Optional[float] = None) -> None: + try: + with open(self.file_path) as file: + if self.file_path.endswith(".yaml"): + data = yaml.safe_load(file) + else: + data = json.load(file) + + self.flag_data = self.parse_flags(data) + logger.debug(f"{self.flag_data=}") + self.provider.emit_provider_configuration_changed( + ProviderEventDetails(flags_changed=list(self.flag_data.keys())) + ) + self.last_modified = modified_time or os.path.getmtime(self.file_path) + except FileNotFoundError: + logger.exception("Provided file path not valid") + except json.JSONDecodeError: + logger.exception("Could not parse JSON flag data from file") + except yaml.error.YAMLError: + logger.exception("Could not parse YAML flag data from file") + except ParseError: + logger.exception("Could not parse flag data using flagd syntax") + except Exception: + logger.exception("Could not read flags from file") + + def parse_flags(self, flags_data: dict) -> dict: + flags = flags_data.get("flags", {}) + 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") + return {key: Flag.from_dict(key, data) for key, data in flags.items()} 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 new file mode 100644 index 00000000..0354ac42 --- /dev/null +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/flags.py @@ -0,0 +1,51 @@ +import typing +from dataclasses import dataclass + +from openfeature.exception import ParseError + + +@dataclass +class Flag: + key: str + state: str + variants: typing.Mapping[str, typing.Any] + default_variant: typing.Union[bool, str] + targeting: typing.Optional[dict] = None + + def __post_init__(self) -> None: + if not self.state or not isinstance(self.state, str): + 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 not self.default_variant or not isinstance( + self.default_variant, (str, bool) + ): + raise ParseError("Incorrect 'defaultVariant' value provided in flag config") + + if self.targeting and not isinstance(self.targeting, dict): + raise ParseError("Incorrect 'targeting' value provided in flag config") + + if self.default_variant not in self.variants: + raise ParseError("Default variant does not match set of variants") + + @classmethod + def from_dict(cls, key: str, data: dict) -> "Flag": + data["default_variant"] = data["defaultVariant"] + del data["defaultVariant"] + flag = cls(key=key, **data) + + return flag + + @property + def default(self) -> typing.Tuple[str, typing.Any]: + return self.get_variant(self.default_variant) + + def get_variant( + self, variant_key: typing.Union[str, bool] + ) -> typing.Tuple[str, typing.Any]: + if isinstance(variant_key, bool): + variant_key = str(variant_key).lower() + + return variant_key, self.variants.get(variant_key) diff --git a/providers/openfeature-provider-flagd/test-harness b/providers/openfeature-provider-flagd/test-harness new file mode 160000 index 00000000..6197b3d9 --- /dev/null +++ b/providers/openfeature-provider-flagd/test-harness @@ -0,0 +1 @@ +Subproject commit 6197b3d956d358bf662e5b8e0aebdc4800480f6b diff --git a/providers/openfeature-provider-flagd/tests/conftest.py b/providers/openfeature-provider-flagd/tests/conftest.py index cdf4bf59..287f5240 100644 --- a/providers/openfeature-provider-flagd/tests/conftest.py +++ b/providers/openfeature-provider-flagd/tests/conftest.py @@ -1,3 +1,5 @@ +import os + import pytest from openfeature import api @@ -8,3 +10,12 @@ def flagd_provider_client(): api.set_provider(FlagdProvider()) return api.get_client() + + +def setup_flag_file(base_dir: str, flag_file: str) -> str: + with open(f"test-harness/flags/{flag_file}") as src_file: + contents = src_file.read() + dst_path = os.path.join(base_dir, flag_file) + with open(dst_path, "w") as dst_file: + dst_file.write(contents) + return dst_path diff --git a/providers/openfeature-provider-flagd/tests/e2e/conftest.py b/providers/openfeature-provider-flagd/tests/e2e/conftest.py new file mode 100644 index 00000000..243f5724 --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/e2e/conftest.py @@ -0,0 +1,208 @@ +import typing + +import pytest +from pytest_bdd import given, parsers, then, when +from tests.e2e.parsers import to_bool + +from openfeature import api +from openfeature.client import OpenFeatureClient +from openfeature.contrib.provider.flagd import FlagdProvider +from openfeature.contrib.provider.flagd.config import ResolverType +from openfeature.evaluation_context import EvaluationContext + +JsonPrimitive = typing.Union[str, bool, float, int] + + +@pytest.fixture +def evaluation_context() -> EvaluationContext: + return EvaluationContext() + + +@given("a flagd provider is set", target_fixture="client") +def setup_provider(flag_file) -> OpenFeatureClient: + api.set_provider( + FlagdProvider( + resolver_type=ResolverType.IN_PROCESS, + offline_flag_source_path=flag_file, + offline_poll_interval_seconds=0.1, + ) + ) + return api.get_client() + + +@when( + parsers.cfparse( + 'a zero-value boolean flag with key "{key}" is evaluated with default value "{default:bool}"', + extra_types={"bool": to_bool}, + ), + target_fixture="key_and_default", +) +@when( + parsers.cfparse( + 'a zero-value string flag with key "{key}" is evaluated with default value "{default}"', + ), + target_fixture="key_and_default", +) +@when( + parsers.cfparse( + 'a string flag with key "{key}" is evaluated with default value "{default}"' + ), + target_fixture="key_and_default", +) +@when( + parsers.cfparse( + 'a zero-value integer flag with key "{key}" is evaluated with default value {default:d}', + ), + target_fixture="key_and_default", +) +@when( + parsers.cfparse( + 'an integer flag with key "{key}" is evaluated with default value {default:d}', + ), + target_fixture="key_and_default", +) +@when( + parsers.cfparse( + 'a zero-value float flag with key "{key}" is evaluated with default value {default:f}', + ), + target_fixture="key_and_default", +) +def setup_key_and_default( + key: str, default: JsonPrimitive +) -> typing.Tuple[str, JsonPrimitive]: + return (key, default) + + +@when( + parsers.cfparse( + 'a context containing a targeting key with value "{targeting_key}"' + ), +) +def assign_targeting_context(evaluation_context: EvaluationContext, targeting_key: str): + """a context containing a targeting key with value .""" + evaluation_context.targeting_key = targeting_key + + +@when( + parsers.cfparse('a context containing a key "{key}", with value "{value}"'), +) +@when( + parsers.cfparse('a context containing a key "{key}", with value {value:d}'), +) +def update_context( + evaluation_context: EvaluationContext, key: str, value: JsonPrimitive +): + """a context containing a key and value.""" + evaluation_context.attributes[key] = value + + +@when( + parsers.cfparse( + 'a context containing a nested property with outer key "{outer}" and inner key "{inner}", with value "{value}"' + ), +) +@when( + parsers.cfparse( + 'a context containing a nested property with outer key "{outer}" and inner key "{inner}", with value {value:d}' + ), +) +def update_context_nested( + evaluation_context: EvaluationContext, + outer: str, + inner: str, + value: typing.Union[str, int], +): + """a context containing a nested property with outer key, and inner key, and value.""" + if outer not in evaluation_context.attributes: + evaluation_context.attributes[outer] = {} + evaluation_context.attributes[outer][inner] = value + + +@then( + parsers.cfparse( + 'the resolved boolean zero-value should be "{expected_value:bool}"', + extra_types={"bool": to_bool}, + ) +) +def assert_boolean_value( + client: OpenFeatureClient, + key_and_default: tuple, + expected_value: bool, + evaluation_context: EvaluationContext, +): + key, default = key_and_default + evaluation_result = client.get_boolean_value(key, default, evaluation_context) + assert evaluation_result == expected_value + + +@then( + parsers.cfparse( + "the resolved integer zero-value should be {expected_value:d}", + ) +) +@then(parsers.cfparse("the returned value should be {expected_value:d}")) +def assert_integer_value( + client: OpenFeatureClient, + key_and_default: tuple, + expected_value: bool, + evaluation_context: EvaluationContext, +): + key, default = key_and_default + evaluation_result = client.get_integer_value(key, default, evaluation_context) + assert evaluation_result == expected_value + + +@then( + parsers.cfparse( + "the resolved float zero-value should be {expected_value:f}", + ) +) +def assert_float_value( + client: OpenFeatureClient, + key_and_default: tuple, + expected_value: bool, + evaluation_context: EvaluationContext, +): + key, default = key_and_default + evaluation_result = client.get_float_value(key, default, evaluation_context) + assert evaluation_result == expected_value + + +@then(parsers.cfparse('the returned value should be "{expected_value}"')) +def assert_string_value( + client: OpenFeatureClient, + key_and_default: tuple, + expected_value: bool, + evaluation_context: EvaluationContext, +): + key, default = key_and_default + evaluation_result = client.get_string_value(key, default, evaluation_context) + assert evaluation_result == expected_value + + +@then( + parsers.cfparse( + 'the resolved string zero-value should be ""', + ) +) +def assert_empty_string( + client: OpenFeatureClient, + key_and_default: tuple, + evaluation_context: EvaluationContext, +): + key, default = key_and_default + evaluation_result = client.get_string_value(key, default, evaluation_context) + assert evaluation_result == "" + + +@then(parsers.cfparse('the returned reason should be "{reason}"')) +def assert_reason( + client: OpenFeatureClient, + key_and_default: tuple, + evaluation_context: EvaluationContext, + reason: str, +): + """the returned reason should be .""" + key, default = key_and_default + evaluation_result = client.get_string_details(key, default, evaluation_context) + assert evaluation_result.reason.value == reason diff --git a/providers/openfeature-provider-flagd/tests/e2e/parsers.py b/providers/openfeature-provider-flagd/tests/e2e/parsers.py new file mode 100644 index 00000000..16e89d94 --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/e2e/parsers.py @@ -0,0 +1,2 @@ +def to_bool(s: str) -> bool: + return s.lower() == "true" diff --git a/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_custom_ops.py b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_custom_ops.py new file mode 100644 index 00000000..70ceb1aa --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_custom_ops.py @@ -0,0 +1,38 @@ +import pytest +from pytest_bdd import scenario +from tests.conftest import setup_flag_file + + +@pytest.fixture +def flag_file(tmp_path): + return setup_flag_file(tmp_path, "custom-ops.json") + + +@scenario( + "../../test-harness/gherkin/flagd-json-evaluator.feature", "Fractional operator" +) +def test_fractional_operator(): + """Fractional operator.""" + + +@scenario( + "../../test-harness/gherkin/flagd-json-evaluator.feature", + "Semantic version operator numeric comparison", +) +def test_semantic_version_operator_numeric_comparison(): + """Semantic version operator numeric comparison.""" + + +@scenario( + "../../test-harness/gherkin/flagd-json-evaluator.feature", + "Semantic version operator semantic comparison", +) +def test_semantic_version_operator_semantic_comparison(): + """Semantic version operator semantic comparison.""" + + +@scenario( + "../../test-harness/gherkin/flagd-json-evaluator.feature", "Substring operators" +) +def test_substring_operators(): + """Substring operators.""" diff --git a/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_edge_cases.py b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_edge_cases.py new file mode 100644 index 00000000..0583d8e9 --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_edge_cases.py @@ -0,0 +1,15 @@ +import pytest +from pytest_bdd import scenario +from tests.conftest import setup_flag_file + + +@pytest.fixture +def flag_file(tmp_path): + return setup_flag_file(tmp_path, "edge-case-flags.json") + + +@scenario( + "../../test-harness/gherkin/flagd-json-evaluator.feature", "Errors and edge cases" +) +def test_errors_and_edge_cases(): + """Errors and edge cases.""" diff --git a/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_evaluator_reuse.py b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_evaluator_reuse.py new file mode 100644 index 00000000..5abcddb5 --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_evaluator_reuse.py @@ -0,0 +1,13 @@ +import pytest +from pytest_bdd import scenario +from tests.conftest import setup_flag_file + + +@pytest.fixture +def flag_file(tmp_path): + return setup_flag_file(tmp_path, "evaluator-refs.json") + + +@scenario("../../test-harness/gherkin/flagd-json-evaluator.feature", "Evaluator reuse") +def test_evaluator_reuse(): + """Evaluator reuse.""" diff --git a/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_events.py b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_events.py new file mode 100644 index 00000000..e00a4844 --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_events.py @@ -0,0 +1,91 @@ +import logging +import time + +import pytest +from pytest_bdd import parsers, scenario, then, when +from tests.conftest import setup_flag_file + +from openfeature.client import OpenFeatureClient, ProviderEvent + + +@scenario("../../test-harness/gherkin/flagd.feature", "Provider ready event") +def test_ready_event(caplog): + """Provider ready event""" + caplog.set_level(logging.DEBUG) + + +@scenario("../../test-harness/gherkin/flagd.feature", "Flag change event") +def test_change_event(): + """Flag change event""" + + +@pytest.fixture +def flag_file(tmp_path): + return setup_flag_file(tmp_path, "changing-flag-bar.json") + + +@pytest.fixture +def handles() -> list: + return [] + + +@when( + parsers.cfparse( + "a {event_type:ProviderEvent} handler is added", + extra_types={"ProviderEvent": ProviderEvent}, + ), + target_fixture="handles", +) +def add_event_handler( + client: OpenFeatureClient, event_type: ProviderEvent, handles: list +): + def handler(event): + handles.append( + { + "type": event_type, + "event": event, + } + ) + + client.add_handler(event_type, handler) + return handles + + +@then( + parsers.cfparse( + "the {event_type:ProviderEvent} handler must run", + extra_types={"ProviderEvent": ProviderEvent}, + ) +) +def assert_handler_run(handles, event_type: ProviderEvent): + max_wait = 2 + poll_interval = 0.1 + while max_wait > 0: + if all(h["type"] != event_type for h in handles): + max_wait -= poll_interval + time.sleep(poll_interval) + continue + break + + assert any(h["type"] == event_type for h in handles) + + +@when(parsers.cfparse('a flag with key "{key}" is modified')) +def modify_flag(flag_file, key): + time.sleep(0.1) # guard against race condition + with open("test-harness/flags/changing-flag-foo.json") as src_file: + contents = src_file.read() + with open(flag_file, "w") as f: + f.write(contents) + + +@then(parsers.cfparse('the event details must indicate "{key}" was altered')) +def assert_flag_changed(handles, key): + handle = None + for h in handles: + if h["type"] == ProviderEvent.PROVIDER_CONFIGURATION_CHANGED: + handle = h + break + + assert handle is not None + assert key in handle["event"].flags_changed diff --git a/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_testing_flags.py b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_testing_flags.py new file mode 100644 index 00000000..4e3bd069 --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_testing_flags.py @@ -0,0 +1,24 @@ +import pytest +from pytest_bdd import scenario +from tests.conftest import setup_flag_file + + +@pytest.fixture +def flag_file(tmp_path): + return setup_flag_file(tmp_path, "testing-flags.json") + + +@scenario( + "../../test-harness/gherkin/flagd-json-evaluator.feature", + "Time-based operations", +) +def test_timebased_operations(): + """Time-based operations.""" + + +@scenario( + "../../test-harness/gherkin/flagd-json-evaluator.feature", + "Targeting by targeting key", +) +def test_targeting_by_targeting_key(): + """Targeting by targeting key.""" diff --git a/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_zero_evals.py b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_zero_evals.py new file mode 100644 index 00000000..30de0dc6 --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_zero_evals.py @@ -0,0 +1,28 @@ +import pytest +from pytest_bdd import scenario +from tests.conftest import setup_flag_file + + +@scenario("../../test-harness/gherkin/flagd.feature", "Resolves boolean zero value") +def test_eval_boolean(): + """Resolve boolean zero value""" + + +@scenario("../../test-harness/gherkin/flagd.feature", "Resolves string zero value") +def test_eval_string(): + """Resolve string zero value""" + + +@scenario("../../test-harness/gherkin/flagd.feature", "Resolves integer zero value") +def test_eval_integer(): + """Resolve integer zero value""" + + +@scenario("../../test-harness/gherkin/flagd.feature", "Resolves float zero value") +def test_eval_float(): + """Resolve float zero value""" + + +@pytest.fixture +def flag_file(tmp_path): + return setup_flag_file(tmp_path, "zero-flags.json") diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag-broken-default.json b/providers/openfeature-provider-flagd/tests/flags/basic-flag-broken-default.json new file mode 100644 index 00000000..4493399a --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag-broken-default.json @@ -0,0 +1,13 @@ +{ + "flags": { + "basic-flag": { + "state": "ENABLED", + "variants": { + "true": true, + "false": false + }, + "defaultVariant": 3, + "targeting": {} + } + } +} \ No newline at end of file diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag-broken-state.json b/providers/openfeature-provider-flagd/tests/flags/basic-flag-broken-state.json new file mode 100644 index 00000000..514c06b8 --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag-broken-state.json @@ -0,0 +1,13 @@ +{ + "flags": { + "basic-flag": { + "state": 3, + "variants": { + "true": true, + "false": false + }, + "defaultVariant": "false", + "targeting": {} + } + } +} \ No newline at end of file diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag-broken-targeting.json b/providers/openfeature-provider-flagd/tests/flags/basic-flag-broken-targeting.json new file mode 100644 index 00000000..5623e05e --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag-broken-targeting.json @@ -0,0 +1,15 @@ +{ + "flags": { + "basic-flag": { + "state": "ENABLED", + "variants": { + "true": true, + "false": false + }, + "defaultVariant": "false", + "targeting": [ + {"<": [1,3]} + ] + } + } +} \ No newline at end of file diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag-broken-variants.json b/providers/openfeature-provider-flagd/tests/flags/basic-flag-broken-variants.json new file mode 100644 index 00000000..6bd94e52 --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag-broken-variants.json @@ -0,0 +1,15 @@ +{ + "flags": { + "basic-flag": { + "state": "ENABLED", + "variants": [ + { + "true": true, + "false": false + } + ], + "defaultVariant": "false", + "targeting": {} + } + } +} \ No newline at end of file diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag-disabled.json b/providers/openfeature-provider-flagd/tests/flags/basic-flag-disabled.json new file mode 100644 index 00000000..8713070b --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag-disabled.json @@ -0,0 +1,13 @@ +{ + "flags": { + "basic-flag": { + "state": "DISABLED", + "variants": { + "true": true, + "false": false + }, + "defaultVariant": "false", + "targeting": {} + } + } + } \ No newline at end of file diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag-invalid.not-json b/providers/openfeature-provider-flagd/tests/flags/basic-flag-invalid.not-json new file mode 100644 index 00000000..69434755 --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag-invalid.not-json @@ -0,0 +1,13 @@ +{ + "flags": { + "basic-flag": { + "state": "ENABLED", + "variants": { + "true": true, + "false": false + }, + "defaultVariant": "false", + "targeting": {} + } + }, + } \ No newline at end of file diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag-no-state.json b/providers/openfeature-provider-flagd/tests/flags/basic-flag-no-state.json new file mode 100644 index 00000000..2c05ee89 --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag-no-state.json @@ -0,0 +1,12 @@ +{ + "flags": { + "basic-flag": { + "variants": { + "true": true, + "false": false + }, + "defaultVariant": "false", + "targeting": {} + } + } +} \ No newline at end of file diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag-wrong-structure.json b/providers/openfeature-provider-flagd/tests/flags/basic-flag-wrong-structure.json new file mode 100644 index 00000000..2d5aeafe --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag-wrong-structure.json @@ -0,0 +1,11 @@ +{ + "basic-flag": { + "state": "ENABLED", + "variants": { + "true": true, + "false": false + }, + "defaultVariant": "false", + "targeting": {} + } +} \ No newline at end of file diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag-wrong-variant.json b/providers/openfeature-provider-flagd/tests/flags/basic-flag-wrong-variant.json new file mode 100644 index 00000000..ea0f8abf --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag-wrong-variant.json @@ -0,0 +1,12 @@ +{ + "flags": { + "basic-flag": { + "state": "ENABLED", + "variants": { + "true": true, + "false": false + }, + "defaultVariant": "a-variant" + } + } +} \ No newline at end of file diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag.json b/providers/openfeature-provider-flagd/tests/flags/basic-flag.json new file mode 100644 index 00000000..9322bd92 --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag.json @@ -0,0 +1,13 @@ +{ + "flags": { + "basic-flag": { + "state": "ENABLED", + "variants": { + "true": true, + "false": false + }, + "defaultVariant": "false", + "targeting": {} + } + } +} \ No newline at end of file diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag.yaml b/providers/openfeature-provider-flagd/tests/flags/basic-flag.yaml new file mode 100644 index 00000000..70a52e9a --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag.yaml @@ -0,0 +1,8 @@ +flags: + basic-flag: + state: ENABLED + variants: + "true": true + "false": false + defaultVariant: "false" + targeting: {} \ No newline at end of file diff --git a/providers/openfeature-provider-flagd/tests/flags/invalid-fractional-args.json b/providers/openfeature-provider-flagd/tests/flags/invalid-fractional-args.json new file mode 100644 index 00000000..0a15b804 --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/invalid-fractional-args.json @@ -0,0 +1,16 @@ +{ + "flags": { + "basic-flag": { + "state": "ENABLED", + "variants": { + "default": "default", + "true": "true", + "false": "false" + }, + "defaultVariant": "default", + "targeting": { + "fractional": [] + } + } + } +} \ No newline at end of file diff --git a/providers/openfeature-provider-flagd/tests/flags/invalid-fractional-weights.json b/providers/openfeature-provider-flagd/tests/flags/invalid-fractional-weights.json new file mode 100644 index 00000000..105b70ac --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/invalid-fractional-weights.json @@ -0,0 +1,19 @@ +{ + "flags": { + "basic-flag": { + "state": "ENABLED", + "variants": { + "default": "default", + "true": "true", + "false": "false" + }, + "defaultVariant": "default", + "targeting": { + "fractional": [ + [3, 50], + [4, 50] + ] + } + } + } +} \ No newline at end of file diff --git a/providers/openfeature-provider-flagd/tests/flags/invalid-semver-args.json b/providers/openfeature-provider-flagd/tests/flags/invalid-semver-args.json new file mode 100644 index 00000000..a16e5ec7 --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/invalid-semver-args.json @@ -0,0 +1,16 @@ +{ + "flags": { + "basic-flag": { + "state": "ENABLED", + "variants": { + "default": "default", + "true": "true", + "false": "false" + }, + "defaultVariant": "default", + "targeting": { + "sem_ver": ["1.0.0", "similar to", "1.0.0", "2.0.0"] + } + } + } +} \ No newline at end of file diff --git a/providers/openfeature-provider-flagd/tests/flags/invalid-semver-op.json b/providers/openfeature-provider-flagd/tests/flags/invalid-semver-op.json new file mode 100644 index 00000000..44e3263e --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/invalid-semver-op.json @@ -0,0 +1,16 @@ +{ + "flags": { + "basic-flag": { + "state": "ENABLED", + "variants": { + "default": "default", + "true": "true", + "false": "false" + }, + "defaultVariant": "default", + "targeting": { + "sem_ver": ["1.0.0", "similar to", "1.0.0"] + } + } + } +} \ No newline at end of file diff --git a/providers/openfeature-provider-flagd/tests/flags/invalid-stringcomp-args.json b/providers/openfeature-provider-flagd/tests/flags/invalid-stringcomp-args.json new file mode 100644 index 00000000..a43afedc --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/invalid-stringcomp-args.json @@ -0,0 +1,16 @@ +{ + "flags": { + "basic-flag": { + "state": "ENABLED", + "variants": { + "default": "default", + "true": "true", + "false": "false" + }, + "defaultVariant": "default", + "targeting": { + "starts_with": ["abcdefg", "abc", "def"] + } + } + } +} \ No newline at end of file diff --git a/providers/openfeature-provider-flagd/tests/test_errors.py b/providers/openfeature-provider-flagd/tests/test_errors.py new file mode 100644 index 00000000..4adb332e --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/test_errors.py @@ -0,0 +1,79 @@ +import pytest + +from openfeature import api +from openfeature.contrib.provider.flagd import FlagdProvider +from openfeature.contrib.provider.flagd.config import ResolverType +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import ErrorCode +from openfeature.flag_evaluation import Reason + + +def create_client(provider: FlagdProvider): + api.set_provider(provider) + return api.get_client() + + +@pytest.mark.parametrize( + "file_name", + [ + "not-a-flag.json", + "basic-flag-wrong-structure.json", + "basic-flag-invalid.not-json", + "basic-flag-wrong-variant.json", + "basic-flag-broken-state.json", + "basic-flag-broken-variants.json", + "basic-flag-broken-default.json", + "basic-flag-broken-targeting.json", + ], +) +def test_file_load_errors(file_name: str): + client = create_client( + FlagdProvider( + resolver_type=ResolverType.IN_PROCESS, + offline_flag_source_path=f"tests/flags/{file_name}", + ) + ) + + res = client.get_boolean_details("basic-flag", False) + + assert res.value is False + assert res.reason == Reason.ERROR + assert res.error_code == ErrorCode.FLAG_NOT_FOUND + + +@pytest.mark.parametrize( + "file_name", + [ + "invalid-semver-op.json", + "invalid-semver-args.json", + "invalid-stringcomp-args.json", + "invalid-fractional-args.json", + "invalid-fractional-weights.json", + ], +) +def test_json_logic_parse_errors(file_name: str): + client = create_client( + FlagdProvider( + resolver_type=ResolverType.IN_PROCESS, + offline_flag_source_path=f"tests/flags/{file_name}", + ) + ) + + res = client.get_string_details("basic-flag", "fallback", EvaluationContext("123")) + + assert res.value == "default" + assert res.reason == Reason.DEFAULT + + +def test_flag_disabled(): + client = create_client( + FlagdProvider( + resolver_type=ResolverType.IN_PROCESS, + offline_flag_source_path="tests/flags/basic-flag-disabled.json", + ) + ) + + res = client.get_string_details("basic-flag", "fallback", EvaluationContext("123")) + + assert res.value == "fallback" + assert res.reason == Reason.DISABLED diff --git a/providers/openfeature-provider-flagd/tests/test_file_store.py b/providers/openfeature-provider-flagd/tests/test_file_store.py new file mode 100644 index 00000000..2ae98ffa --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/test_file_store.py @@ -0,0 +1,33 @@ +from unittest.mock import Mock + +import pytest +from src.openfeature.contrib.provider.flagd.resolvers.process.file_watcher import ( + FileWatcherFlagStore, +) +from src.openfeature.contrib.provider.flagd.resolvers.process.flags import Flag + +from openfeature import api +from openfeature.contrib.provider.flagd import FlagdProvider +from openfeature.provider.provider import AbstractProvider + + +def create_client(provider: FlagdProvider): + api.set_provider(provider) + return api.get_client() + + +@pytest.mark.parametrize( + "file_name", + [ + "basic-flag.json", + "basic-flag.yaml", + ], +) +def test_file_load_errors(file_name: str): + provider = Mock(spec=AbstractProvider) + file_store = FileWatcherFlagStore(f"tests/flags/{file_name}", provider) + + flag = file_store.flag_data.get("basic-flag") + + assert flag is not None + assert isinstance(flag, Flag)