From 25f4dffbea80d392bde3081c0f66837b13210a15 Mon Sep 17 00:00:00 2001 From: Cole Bailey Date: Fri, 29 Mar 2024 21:20:40 +0100 Subject: [PATCH 01/25] Move grpc logic to resolver Signed-off-by: Cole Bailey --- .../contrib/provider/flagd/config.py | 12 ++ .../contrib/provider/flagd/provider.py | 117 +++----------- .../contrib/provider/flagd/resolvers/grpc.py | 145 ++++++++++++++++++ 3 files changed, 181 insertions(+), 93 deletions(-) create mode 100644 providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py 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 e2db98a..05d22e7 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,6 +18,11 @@ 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__( self, @@ -24,6 +30,7 @@ def __init__( port: typing.Optional[int] = None, tls: typing.Optional[bool] = None, timeout: typing.Optional[int] = None, + resolver_type: typing.Optional[ResolverType] = None, ): self.host = env_or_default("FLAGD_HOST", "localhost") if host is None else host self.port = ( @@ -33,3 +40,8 @@ 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 + ) 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 ea91a1d..9c7ddbb 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.grpc import GrpcResolver T = typing.TypeVar("T") @@ -54,6 +43,7 @@ def __init__( port: typing.Optional[int] = None, tls: typing.Optional[bool] = None, timeout: typing.Optional[int] = None, + resolver_type: typing.Optional[ResolverType] = None, ): """ Create an instance of the FlagdProvider @@ -68,14 +58,17 @@ def __init__( port=port, tls=tls, timeout=timeout, + resolver_type=resolver_type, ) - 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) + if self.config.resolver_type == ResolverType.GRPC: + self.resolver = GrpcResolver(self.config) + else: + raise ValueError("`resolver_type` parameter invalid") def shutdown(self) -> None: - self.channel.close() + if self.resolver: + self.resolver.shutdown() def get_metadata(self) -> Metadata: """Returns provider metadata""" @@ -87,7 +80,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 +90,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 +100,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 +110,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 +120,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/grpc.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py new file mode 100644 index 0000000..caab101 --- /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 From aa83b92846e9a280493ab45ef3b50febfcd6acc0 Mon Sep 17 00:00:00 2001 From: Cole Bailey Date: Fri, 29 Mar 2024 23:07:10 +0100 Subject: [PATCH 02/25] Working file watcher and fractional op Signed-off-by: Cole Bailey --- .../openfeature-provider-flagd/pyproject.toml | 2 + .../contrib/provider/flagd/config.py | 8 +- .../contrib/provider/flagd/provider.py | 17 +++- .../provider/flagd/resolvers/__init__.py | 51 ++++++++++ .../provider/flagd/resolvers/in_process.py | 98 +++++++++++++++++++ .../flagd/resolvers/process/file_watcher.py | 36 +++++++ .../flagd/resolvers/process/fractional_op.py | 27 +++++ 7 files changed, 234 insertions(+), 5 deletions(-) create mode 100644 providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/__init__.py create mode 100644 providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/in_process.py create mode 100644 providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/file_watcher.py create mode 100644 providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/fractional_op.py diff --git a/providers/openfeature-provider-flagd/pyproject.toml b/providers/openfeature-provider-flagd/pyproject.toml index cd435bc..38b4e3b 100644 --- a/providers/openfeature-provider-flagd/pyproject.toml +++ b/providers/openfeature-provider-flagd/pyproject.toml @@ -20,6 +20,8 @@ dependencies = [ "openfeature-sdk>=0.4.0", "grpcio>=1.60.0", "protobuf>=4.25.2", + "mmh3>=4.1.0", + "panzi-json-logic>=1.0.1", ] requires-python = ">=3.8" 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 05d22e7..eddaac1 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 @@ -24,13 +24,14 @@ class ResolverType(Enum): 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, ): self.host = env_or_default("FLAGD_HOST", "localhost") if host is None else host self.port = ( @@ -45,3 +46,8 @@ def __init__( 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 + ) 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 9c7ddbb..cdb6fe4 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 @@ -29,7 +29,7 @@ from openfeature.provider.provider import AbstractProvider from .config import Config, ResolverType -from .resolvers.grpc import GrpcResolver +from .resolvers import AbstractResolver, GrpcResolver, InProcessResolver T = typing.TypeVar("T") @@ -37,13 +37,14 @@ 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, ): """ Create an instance of the FlagdProvider @@ -59,12 +60,20 @@ def __init__( tls=tls, timeout=timeout, resolver_type=resolver_type, + offline_flag_source_path=offline_flag_source_path, ) + self.resolver = self.setup_resolver() + + def setup_resolver(self) -> AbstractResolver: if self.config.resolver_type == ResolverType.GRPC: - self.resolver = GrpcResolver(self.config) + return GrpcResolver(self.config) + elif self.config.resolver_type == ResolverType.IN_PROCESS: + return InProcessResolver(self.config) else: - raise ValueError("`resolver_type` parameter invalid") + raise ValueError( + f"`resolver_type` parameter invalid: {self.config.resolver_type}" + ) def shutdown(self) -> None: if self.resolver: 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 0000000..53e1793 --- /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/in_process.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/in_process.py new file mode 100644 index 0000000..f2375e1 --- /dev/null +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/in_process.py @@ -0,0 +1,98 @@ +import typing + +from json_logic import builtins, jsonLogic + +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import FlagNotFoundError +from openfeature.flag_evaluation import FlagResolutionDetails, Reason + +from ..config import Config +from ..flag_type import FlagType +from .process.file_watcher import FileWatcherFlagStore +from .process.fractional_op import fractional + +T = typing.TypeVar("T") + + +class InProcessResolver: + def __init__(self, config: Config): + self.config = config + 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) + + 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, 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, + key: str, + flag_type: FlagType, + 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"] != "ENABLED": + return FlagResolutionDetails(default_value, reason=Reason.DISABLED) + + ops = {**builtins.BUILTINS, "fractional": fractional} + + json_logic_context = evaluation_context.attributes if evaluation_context else {} + json_logic_context["$flagd"] = {"flagKey": key} + variant = jsonLogic(flag["targeting"], json_logic_context, ops) + + variants = flag["variants"] + value = flag["variants"].get( + variant, variants.get(flag.get("defaultVariant"), default_value) + ) + # TODO: Check type matches + + return FlagResolutionDetails( + value, + reason=Reason.TARGETING_MATCH, + variant=variant, + ) 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 0000000..66ce5e3 --- /dev/null +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/file_watcher.py @@ -0,0 +1,36 @@ +import json +import logging +import os +import threading +import time +import typing + + +class FileWatcherFlagStore: + def __init__(self, file_path: str): + self.file_path = file_path + self.last_modified = 0.0 + 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[dict]: + return self.flag_data.get(key) + + def refresh_file(self) -> None: + while True: + time.sleep(1) + logging.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: + # TODO: error handling + with open(self.file_path) as file: + self.flag_data: dict = json.load(file).get("flags", {}) + logging.debug(f"{self.flag_data=}") + self.last_modified = modified_time or os.path.getmtime(self.file_path) diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/fractional_op.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/fractional_op.py new file mode 100644 index 0000000..21d9ea8 --- /dev/null +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/fractional_op.py @@ -0,0 +1,27 @@ +import typing + +import mmh3 + + +def fractional(data: dict, *arr: tuple[str, int]) -> typing.Optional[str]: + bucket_by = None + if isinstance(arr[0], str): + bucket_by = arr[0] + arr = arr[1:] + else: + bucket_by = data.get("targetingKey") + + if not bucket_by: + return None + + hash_key = data.get("$flagd", {}).get("flagKey", "") + bucket_by + hash_ratio = abs(mmh3.hash(hash_key)) / (2**31 - 1) + bucket = int(hash_ratio * 100) + + range_end = 0 + for variant, weight in arr: + range_end += weight + if bucket < range_end: + return variant + + return None From 355ea930111fbe6a5ca546a32d96c5930bebcfe5 Mon Sep 17 00:00:00 2001 From: Cole Bailey Date: Sat, 30 Mar 2024 01:09:47 +0100 Subject: [PATCH 03/25] working gherkin scenarios Signed-off-by: Cole Bailey --- .gitmodules | 3 + .../openfeature-provider-flagd/pyproject.toml | 1 + .../contrib/provider/flagd/provider.py | 2 +- .../provider/flagd/resolvers/in_process.py | 18 ++- .../flagd/resolvers/process/file_watcher.py | 9 +- .../openfeature-provider-flagd/test-harness | 1 + .../tests/e2e/parsers.py | 2 + .../tests/e2e/test_inprocess_events.py | 99 ++++++++++++++++ .../tests/e2e/test_inprocess_zero_evals.py | 112 ++++++++++++++++++ 9 files changed, 239 insertions(+), 8 deletions(-) create mode 160000 providers/openfeature-provider-flagd/test-harness create mode 100644 providers/openfeature-provider-flagd/tests/e2e/parsers.py create mode 100644 providers/openfeature-provider-flagd/tests/e2e/test_inprocess_events.py create mode 100644 providers/openfeature-provider-flagd/tests/e2e/test_inprocess_zero_evals.py diff --git a/.gitmodules b/.gitmodules index 0a552e2..a8bef85 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/providers/openfeature-provider-flagd/pyproject.toml b/providers/openfeature-provider-flagd/pyproject.toml index 38b4e3b..3322fa1 100644 --- a/providers/openfeature-provider-flagd/pyproject.toml +++ b/providers/openfeature-provider-flagd/pyproject.toml @@ -34,6 +34,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" 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 cdb6fe4..19b7d4e 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 @@ -69,7 +69,7 @@ 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) + return InProcessResolver(self.config, self) else: raise ValueError( f"`resolver_type` parameter invalid: {self.config.resolver_type}" 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 f2375e1..d0149f1 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 @@ -5,6 +5,7 @@ from openfeature.evaluation_context import EvaluationContext from openfeature.exception import FlagNotFoundError from openfeature.flag_evaluation import FlagResolutionDetails, Reason +from openfeature.provider.provider import AbstractProvider from ..config import Config from ..flag_type import FlagType @@ -15,13 +16,16 @@ class InProcessResolver: - def __init__(self, config: Config): + 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.flag_store = FileWatcherFlagStore( + self.config.offline_flag_source_path, self.provider + ) def shutdown(self) -> None: self.flag_store.shutdown() @@ -79,16 +83,18 @@ def _resolve( if flag["state"] != "ENABLED": return FlagResolutionDetails(default_value, reason=Reason.DISABLED) + variants = flag["variants"] + default = variants.get(flag.get("defaultVariant"), default_value) + if "targeting" not in flag: + return FlagResolutionDetails(default, reason=Reason.STATIC) + ops = {**builtins.BUILTINS, "fractional": fractional} json_logic_context = evaluation_context.attributes if evaluation_context else {} json_logic_context["$flagd"] = {"flagKey": key} variant = jsonLogic(flag["targeting"], json_logic_context, ops) - variants = flag["variants"] - value = flag["variants"].get( - variant, variants.get(flag.get("defaultVariant"), default_value) - ) + value = flag["variants"].get(variant, default) # TODO: Check type matches return FlagResolutionDetails( 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 index 66ce5e3..22b35bc 100644 --- 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 @@ -5,10 +5,14 @@ import time import typing +from openfeature.event import ProviderEventDetails +from openfeature.provider.provider import AbstractProvider + class FileWatcherFlagStore: - def __init__(self, file_path: str): + def __init__(self, file_path: str, provider: AbstractProvider): self.file_path = file_path + self.provider = provider self.last_modified = 0.0 self.load_data() self.thread = threading.Thread(target=self.refresh_file, daemon=True) @@ -33,4 +37,7 @@ def load_data(self, modified_time: typing.Optional[float] = None) -> None: with open(self.file_path) as file: self.flag_data: dict = json.load(file).get("flags", {}) logging.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) diff --git a/providers/openfeature-provider-flagd/test-harness b/providers/openfeature-provider-flagd/test-harness new file mode 160000 index 0000000..3d2c5ea --- /dev/null +++ b/providers/openfeature-provider-flagd/test-harness @@ -0,0 +1 @@ +Subproject commit 3d2c5ea60de260d800b12a2d9447a25e8a995ac0 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 0000000..16e89d9 --- /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_events.py b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_events.py new file mode 100644 index 0000000..2b44452 --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_events.py @@ -0,0 +1,99 @@ +import logging +import os +import time + +import pytest +from pytest_bdd import given, parsers, scenario, then, when + +from openfeature import api +from openfeature.client import OpenFeatureClient, ProviderEvent +from openfeature.contrib.provider.flagd import FlagdProvider +from openfeature.contrib.provider.flagd.config import ResolverType + + +@scenario("../../test-harness/gherkin/flagd.feature", "Provider ready event") +@scenario("../../test-harness/gherkin/flagd.feature", "Flag change event") +def test_event_scenarios(caplog): + caplog.set_level(logging.DEBUG) + + +@pytest.fixture +def flag_file(tmp_path): + with open("test-harness/flags/changing-flag-bar.json") as src_file: + contents = src_file.read() + dst_path = os.path.join(tmp_path, "changing-flag-bar.json") + with open(dst_path, "w") as dst_file: + dst_file.write(contents) + return dst_path + + +@pytest.fixture +def handles() -> list: + return [] + + +@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, + ) + ) + return api.get_client() + + +# events +@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): + if all(h["type"] != event_type for h in handles): + time.sleep(2) + + 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): + 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_zero_evals.py b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_zero_evals.py new file mode 100644 index 0000000..570cc76 --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_zero_evals.py @@ -0,0 +1,112 @@ +import os + +import pytest +from pytest_bdd import given, parsers, scenario, 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 + +# scenario = partial(pytest_bdd.scenario, "../../test-harness/gherkin/flagd.feature") + + +@scenario("../../test-harness/gherkin/flagd.feature", "Resolves boolean zero value") +@scenario("../../test-harness/gherkin/flagd.feature", "Resolves string zero value") +@scenario("../../test-harness/gherkin/flagd.feature", "Resolves integer zero value") +@scenario("../../test-harness/gherkin/flagd.feature", "Resolves float zero value") +def test_event_scenarios(): + pass + + +@pytest.fixture +def flag_file(tmp_path): + with open("test-harness/flags/zero-flags.json") as src_file: + contents = src_file.read() + dst_path = os.path.join(tmp_path, "zero-flags.json") + with open(dst_path, "w") as dst_file: + dst_file.write(contents) + return dst_path + + +@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, + ) + ) + return api.get_client() + + +# zero evaluation +@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="evaluation_result", +) +def evaluate_bool(client: OpenFeatureClient, key: str, default: bool) -> bool: + return client.get_boolean_value(key, default) + + +@when( + parsers.cfparse( + 'a zero-value string flag with key "{key}" is evaluated with default value "{default}"', + ), + target_fixture="evaluation_result_str", +) +def evaluate_string(client: OpenFeatureClient, key: str, default: str) -> str: + return client.get_string_value(key, default) + + +@when( + parsers.cfparse( + 'a zero-value integer flag with key "{key}" is evaluated with default value {default:d}', + ), + target_fixture="evaluation_result", +) +def evaluate_integer(client: OpenFeatureClient, key: str, default: int) -> int: + return client.get_integer_value(key, default) + + +@when( + parsers.cfparse( + 'a zero-value float flag with key "{key}" is evaluated with default value {default:f}', + ), + target_fixture="evaluation_result", +) +def evaluate_float(client: OpenFeatureClient, key: str, default: float) -> float: + return client.get_float_value(key, default) + + +@then( + parsers.cfparse( + 'the resolved boolean zero-value should be "{expected_value:bool}"', + extra_types={"bool": to_bool}, + ) +) +@then( + parsers.cfparse( + "the resolved integer zero-value should be {expected_value:d}", + ) +) +@then( + parsers.cfparse( + "the resolved float zero-value should be {expected_value:f}", + ) +) +def assert_value(evaluation_result, expected_value): + assert evaluation_result == expected_value + + +@then( + parsers.cfparse( + 'the resolved string zero-value should be ""', + ) +) +def assert_empty_string(evaluation_result_str): + assert evaluation_result_str == "" From 2162a580a30e32c19242110bbf88b8faacb3e5cd Mon Sep 17 00:00:00 2001 From: Cole Bailey Date: Sat, 30 Mar 2024 13:02:47 +0100 Subject: [PATCH 04/25] fractional op tests Signed-off-by: Cole Bailey --- .../flagd/resolvers/process/fractional_op.py | 3 +- .../tests/conftest.py | 11 ++ .../tests/e2e/conftest.py | 138 +++++++++++++++++ .../tests/e2e/test_inprocess_custom_ops.py | 139 ++++++++++++++++++ .../tests/e2e/test_inprocess_events.py | 35 ++--- .../tests/e2e/test_inprocess_zero_evals.py | 116 ++------------- 6 files changed, 316 insertions(+), 126 deletions(-) create mode 100644 providers/openfeature-provider-flagd/tests/e2e/conftest.py create mode 100644 providers/openfeature-provider-flagd/tests/e2e/test_inprocess_custom_ops.py diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/fractional_op.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/fractional_op.py index 21d9ea8..9693183 100644 --- a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/fractional_op.py +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/fractional_op.py @@ -14,7 +14,8 @@ def fractional(data: dict, *arr: tuple[str, int]) -> typing.Optional[str]: if not bucket_by: return None - hash_key = data.get("$flagd", {}).get("flagKey", "") + bucket_by + seed = data.get("$flagd", {}).get("flagKey", "") + hash_key = seed + bucket_by hash_ratio = abs(mmh3.hash(hash_key)) / (2**31 - 1) bucket = int(hash_ratio * 100) diff --git a/providers/openfeature-provider-flagd/tests/conftest.py b/providers/openfeature-provider-flagd/tests/conftest.py index cdf4bf5..287f524 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 0000000..84e79ff --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/e2e/conftest.py @@ -0,0 +1,138 @@ +from typing import Any + +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 + + +@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, + ) + ) + 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( + '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: Any) -> tuple[str, Any]: + return (key, default) + + +@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=None, +): + 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}", + ) +) +def assert_integer_value( + client: OpenFeatureClient, + key_and_default: tuple, + expected_value: bool, + evaluation_context=None, +): + 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=None, +): + 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=None, +): + key, default = key_and_default + evaluation_result = client.get_string_value(key, default, evaluation_context) + assert evaluation_result == "" 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 0000000..3161920 --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_custom_ops.py @@ -0,0 +1,139 @@ +import typing + +import pytest +from pytest_bdd import parsers, scenario, then, when +from tests.conftest import setup_flag_file + +from openfeature.evaluation_context import EvaluationContext + + +@pytest.fixture +def flag_file(tmp_path): + return setup_flag_file(tmp_path, "custom-ops.json") + + +# @scenario( "../../test-harness/gherkin/flagd-json-evaluator.feature", "Errors and edge cases") +# def test_errors_and_edge_cases(): +# """Errors and edge cases.""" + + +# @scenario( "../../test-harness/gherkin/flagd-json-evaluator.feature", "Evaluator reuse") +# def test_evaluator_reuse(): +# """Evaluator reuse.""" + + +@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.""" + + +# @scenario( "../../test-harness/gherkin/flagd-json-evaluator.feature", "Targeting by targeting key") +# def test_targeting_by_targeting_key(): +# """Targeting by targeting key.""" + + +# @scenario( "../../test-harness/gherkin/flagd-json-evaluator.feature", "Time-based operations") +# def test_timebased_operations(): +# """Time-based operations.""" + + +@when('a context containing a key "email", with value "ballmer@macrosoft.com"') +def _(): + """a context containing a key "email", with value "ballmer@macrosoft.com".""" + raise NotImplementedError + + +@when('a context containing a key "id", with value ') +def _(): + """a context containing a key "id", with value .""" + raise NotImplementedError + + +@when('a context containing a key "time", with value