Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@
[submodule "providers/openfeature-provider-flagd/openfeature/test-harness"]
path = providers/openfeature-provider-flagd/openfeature/test-harness
url = https://github.com/open-feature/flagd-testbed.git
branch = v2.11.1
branch = v3.5.0
5 changes: 4 additions & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,8 @@
"providers/openfeature-provider-flagd": "0.2.6",
"providers/openfeature-provider-ofrep": "0.2.0",
"providers/openfeature-provider-flipt": "0.1.3",
"providers/openfeature-provider-env-var": "0.1.0"
"providers/openfeature-provider-env-var": "0.1.0",
"tools/openfeature-flagd-api": "0.1.0",
"tools/openfeature-flagd-core": "0.1.0",
"tools/openfeature-flagd-api-testkit": "0.1.0"
}
7 changes: 4 additions & 3 deletions providers/openfeature-provider-flagd/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,9 @@ classifiers = [
keywords = []
dependencies = [
"openfeature-sdk>=0.8.2",
"openfeature-flagd-core",
"grpcio>=1.68.1",
"protobuf>=5.26.1",
"mmh3>=4.1.0",
"panzi-json-logic>=1.0.1",
"semver>=3,<4",
"pyyaml>=6.0.1",
"cachebox; python_version >= '3.10'",
# version `5.0.1` wheels for Python 3.9 on macOS were the last one for now,
Expand Down Expand Up @@ -112,6 +110,9 @@ module = [
]
warn_unused_ignores = false

[tool.uv.sources]
openfeature-flagd-core = { workspace = true }

[project.scripts]
# workaround while UV doesn't support scripts directly in the pyproject.toml
# see: https://github.com/astral-sh/uv/issues/5903
Expand Down
Original file line number Diff line number Diff line change
@@ -1,37 +1,41 @@
import json
import typing

from openfeature.contrib.provider.flagd.resolvers.process.connector.file_watcher import (
FileWatcher,
)
from openfeature.contrib.tools.flagd.core import FlagdCore
from openfeature.evaluation_context import EvaluationContext
from openfeature.event import ProviderEventDetails
from openfeature.exception import ErrorCode, FlagNotFoundError, GeneralError, ParseError
from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType, Reason
from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType

from ..config import Config
from .process.connector import FlagStateConnector
from .process.connector.file_watcher import FileWatcher
from .process.connector.grpc_watcher import GrpcWatcher
from .process.flags import Flag, FlagStore
from .process.targeting import targeting

T = typing.TypeVar("T")


def _merge_metadata(
flag_metadata: typing.Optional[
typing.Mapping[str, typing.Union[float, int, str, bool]]
],
flag_set_metadata: typing.Optional[
typing.Mapping[str, typing.Union[float, int, str, bool]]
],
) -> typing.Mapping[str, typing.Union[float, int, str, bool]]:
metadata = {} if flag_set_metadata is None else dict(flag_set_metadata)
class _FlagStoreAdapter:
"""Bridges FlagdCore with connectors that expect a FlagStore-like update() interface."""

if flag_metadata is not None:
for key, value in flag_metadata.items():
metadata[key] = value

return metadata
def __init__(
self,
evaluator: FlagdCore,
emit_provider_configuration_changed: typing.Callable[
[ProviderEventDetails], None
],
):
self.evaluator = evaluator
self.emit_provider_configuration_changed = emit_provider_configuration_changed

def update(self, flags_data: dict) -> None:
json_str = json.dumps(flags_data)
changed_keys = self.evaluator.set_flags_and_get_changed_keys(json_str)
metadata = self.evaluator.get_flag_set_metadata()
self.emit_provider_configuration_changed(
ProviderEventDetails(
flags_changed=changed_keys, metadata=dict(metadata)
)
)


class InProcessResolver:
Expand All @@ -46,15 +50,25 @@ def __init__(
],
):
self.config = config
self.flag_store = FlagStore(emit_provider_configuration_changed)
self.evaluator = FlagdCore()

# Adapter lets connectors push flag data to FlagdCore via the
# same .update(dict) interface they used with the old FlagStore.
flag_store_adapter = _FlagStoreAdapter(
self.evaluator, emit_provider_configuration_changed
)

self.connector: FlagStateConnector = (
FileWatcher(
self.config, self.flag_store, emit_provider_ready, emit_provider_error
self.config,
flag_store_adapter, # type: ignore[arg-type]
emit_provider_ready,
emit_provider_error,
)
if self.config.offline_flag_source_path
else GrpcWatcher(
self.config,
self.flag_store,
flag_store_adapter, # type: ignore[arg-type]
emit_provider_ready,
emit_provider_error,
emit_provider_stale,
Expand All @@ -73,34 +87,31 @@ def resolve_boolean_details(
default_value: bool,
evaluation_context: typing.Optional[EvaluationContext] = None,
) -> FlagResolutionDetails[bool]:
return self._resolve(key, default_value, evaluation_context)
return self.evaluator.resolve_boolean_value(key, default_value, evaluation_context)

def resolve_string_details(
self,
key: str,
default_value: str,
evaluation_context: typing.Optional[EvaluationContext] = None,
) -> FlagResolutionDetails[str]:
return self._resolve(key, default_value, evaluation_context)
return self.evaluator.resolve_string_value(key, default_value, evaluation_context)

def resolve_float_details(
self,
key: str,
default_value: float,
evaluation_context: typing.Optional[EvaluationContext] = None,
) -> FlagResolutionDetails[float]:
result = self._resolve(key, default_value, evaluation_context)
if isinstance(result.value, int):
result.value = float(result.value)
return result
return self.evaluator.resolve_float_value(key, default_value, evaluation_context)

def resolve_integer_details(
self,
key: str,
default_value: int,
evaluation_context: typing.Optional[EvaluationContext] = None,
) -> FlagResolutionDetails[int]:
return self._resolve(key, default_value, evaluation_context)
return self.evaluator.resolve_integer_value(key, default_value, evaluation_context)

def resolve_object_details(
self,
Expand All @@ -112,75 +123,4 @@ def resolve_object_details(
) -> FlagResolutionDetails[
typing.Union[typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]]
]:
return self._resolve(key, default_value, evaluation_context)

def _resolve(
self,
key: str,
default_value: T,
evaluation_context: typing.Optional[EvaluationContext] = None,
) -> FlagResolutionDetails[T]:
flag = self.flag_store.get_flag(key)
if not flag:
raise FlagNotFoundError(f"Flag with key {key} not present in flag store.")

metadata = _merge_metadata(flag.metadata, self.flag_store.flag_set_metadata)

if flag.state == "DISABLED":
return FlagResolutionDetails(
default_value, flag_metadata=metadata, reason=Reason.DISABLED
)

if not flag.targeting:
return _default_resolve(flag, metadata, Reason.STATIC)

try:
variant = targeting(flag.key, flag.targeting, evaluation_context)
if variant is None:
return _default_resolve(flag, metadata, Reason.DEFAULT)

# convert to string to support shorthand (boolean in python is with capital T hence the special case)
if isinstance(variant, bool):
variant = str(variant).lower()
elif not isinstance(variant, str):
variant = str(variant)

if variant not in flag.variants:
raise GeneralError(
f"Resolved variant {variant} not in variants config."
)

except ReferenceError:
raise ParseError(f"Invalid targeting {targeting}") from ReferenceError

variant, value = flag.get_variant(variant)
if value is None:
raise GeneralError(f"Resolved variant {variant} not in variants config.")

return FlagResolutionDetails(
value,
variant=variant,
reason=Reason.TARGETING_MATCH,
flag_metadata=metadata,
)


def _default_resolve(
flag: Flag,
metadata: typing.Mapping[str, typing.Union[float, int, str, bool]],
reason: Reason,
) -> FlagResolutionDetails:
variant, value = flag.default
if variant is None:
return FlagResolutionDetails(
value,
variant=variant,
reason=Reason.ERROR,
error_code=ErrorCode.FLAG_NOT_FOUND,
flag_metadata=metadata,
)
if variant not in flag.variants:
raise GeneralError(f"Resolved variant {variant} not in variants config.")
return FlagResolutionDetails(
value, variant=variant, flag_metadata=metadata, reason=reason
)
return self.evaluator.resolve_object_value(key, default_value, evaluation_context)
Loading
Loading