Skip to content

Commit 75589ae

Browse files
aepfliclaude
andcommitted
feat: extract flagd evaluator into api, core, and testkit packages
Split the flagd evaluation logic from the provider into three independent packages under tools/, mirroring the Java SDK contrib architecture (PRs #1696 and #1742): - openfeature-flagd-api: Evaluator Protocol defining the contract for flag evaluation implementations - openfeature-flagd-core: Reference implementation with FlagdCore class, targeting engine, and custom operators (fractional, sem_ver, starts_with, ends_with) - openfeature-flagd-api-testkit: Compliance test suite bundling gherkin feature files from the test-harness evaluator directory The provider's InProcessResolver now delegates to FlagdCore via an adapter pattern, keeping connector code (FileWatcher, GrpcWatcher) unchanged. Old provider modules (flags.py, targeting.py, custom_ops.py) are thin re-exports from the core package for backward compatibility. Also updates the test-harness submodule from v2.11.1 to v3.5.0. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Simon Schrottner <simon.schrottner@dynatrace.com>
1 parent eb28106 commit 75589ae

65 files changed

Lines changed: 3772 additions & 477 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitmodules

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@
88
[submodule "providers/openfeature-provider-flagd/openfeature/test-harness"]
99
path = providers/openfeature-provider-flagd/openfeature/test-harness
1010
url = https://github.com/open-feature/flagd-testbed.git
11-
branch = v2.11.1
11+
branch = v3.5.0

.release-please-manifest.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,8 @@
33
"providers/openfeature-provider-flagd": "0.2.6",
44
"providers/openfeature-provider-ofrep": "0.2.0",
55
"providers/openfeature-provider-flipt": "0.1.3",
6-
"providers/openfeature-provider-env-var": "0.1.0"
6+
"providers/openfeature-provider-env-var": "0.1.0",
7+
"tools/openfeature-flagd-api": "0.1.0",
8+
"tools/openfeature-flagd-core": "0.1.0",
9+
"tools/openfeature-flagd-api-testkit": "0.1.0"
710
}

providers/openfeature-provider-flagd/pyproject.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,9 @@ classifiers = [
1818
keywords = []
1919
dependencies = [
2020
"openfeature-sdk>=0.8.2",
21+
"openfeature-flagd-core",
2122
"grpcio>=1.68.1",
2223
"protobuf>=5.26.1",
23-
"mmh3>=4.1.0",
24-
"panzi-json-logic>=1.0.1",
25-
"semver>=3,<4",
2624
"pyyaml>=6.0.1",
2725
"cachebox; python_version >= '3.10'",
2826
# version `5.0.1` wheels for Python 3.9 on macOS were the last one for now,
@@ -112,6 +110,9 @@ module = [
112110
]
113111
warn_unused_ignores = false
114112

113+
[tool.uv.sources]
114+
openfeature-flagd-core = { workspace = true }
115+
115116
[project.scripts]
116117
# workaround while UV doesn't support scripts directly in the pyproject.toml
117118
# see: https://github.com/astral-sh/uv/issues/5903
Lines changed: 43 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,41 @@
1+
import json
12
import typing
23

3-
from openfeature.contrib.provider.flagd.resolvers.process.connector.file_watcher import (
4-
FileWatcher,
5-
)
4+
from openfeature.contrib.tools.flagd.core import FlagdCore
65
from openfeature.evaluation_context import EvaluationContext
76
from openfeature.event import ProviderEventDetails
8-
from openfeature.exception import ErrorCode, FlagNotFoundError, GeneralError, ParseError
9-
from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType, Reason
7+
from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType
108

119
from ..config import Config
1210
from .process.connector import FlagStateConnector
11+
from .process.connector.file_watcher import FileWatcher
1312
from .process.connector.grpc_watcher import GrpcWatcher
14-
from .process.flags import Flag, FlagStore
15-
from .process.targeting import targeting
1613

1714
T = typing.TypeVar("T")
1815

1916

20-
def _merge_metadata(
21-
flag_metadata: typing.Optional[
22-
typing.Mapping[str, typing.Union[float, int, str, bool]]
23-
],
24-
flag_set_metadata: typing.Optional[
25-
typing.Mapping[str, typing.Union[float, int, str, bool]]
26-
],
27-
) -> typing.Mapping[str, typing.Union[float, int, str, bool]]:
28-
metadata = {} if flag_set_metadata is None else dict(flag_set_metadata)
17+
class _FlagStoreAdapter:
18+
"""Bridges FlagdCore with connectors that expect a FlagStore-like update() interface."""
2919

30-
if flag_metadata is not None:
31-
for key, value in flag_metadata.items():
32-
metadata[key] = value
33-
34-
return metadata
20+
def __init__(
21+
self,
22+
evaluator: FlagdCore,
23+
emit_provider_configuration_changed: typing.Callable[
24+
[ProviderEventDetails], None
25+
],
26+
):
27+
self.evaluator = evaluator
28+
self.emit_provider_configuration_changed = emit_provider_configuration_changed
29+
30+
def update(self, flags_data: dict) -> None:
31+
json_str = json.dumps(flags_data)
32+
changed_keys = self.evaluator.set_flags_and_get_changed_keys(json_str)
33+
metadata = self.evaluator.get_flag_set_metadata()
34+
self.emit_provider_configuration_changed(
35+
ProviderEventDetails(
36+
flags_changed=changed_keys, metadata=dict(metadata)
37+
)
38+
)
3539

3640

3741
class InProcessResolver:
@@ -46,15 +50,25 @@ def __init__(
4650
],
4751
):
4852
self.config = config
49-
self.flag_store = FlagStore(emit_provider_configuration_changed)
53+
self.evaluator = FlagdCore()
54+
55+
# Adapter lets connectors push flag data to FlagdCore via the
56+
# same .update(dict) interface they used with the old FlagStore.
57+
flag_store_adapter = _FlagStoreAdapter(
58+
self.evaluator, emit_provider_configuration_changed
59+
)
60+
5061
self.connector: FlagStateConnector = (
5162
FileWatcher(
52-
self.config, self.flag_store, emit_provider_ready, emit_provider_error
63+
self.config,
64+
flag_store_adapter, # type: ignore[arg-type]
65+
emit_provider_ready,
66+
emit_provider_error,
5367
)
5468
if self.config.offline_flag_source_path
5569
else GrpcWatcher(
5670
self.config,
57-
self.flag_store,
71+
flag_store_adapter, # type: ignore[arg-type]
5872
emit_provider_ready,
5973
emit_provider_error,
6074
emit_provider_stale,
@@ -73,34 +87,31 @@ def resolve_boolean_details(
7387
default_value: bool,
7488
evaluation_context: typing.Optional[EvaluationContext] = None,
7589
) -> FlagResolutionDetails[bool]:
76-
return self._resolve(key, default_value, evaluation_context)
90+
return self.evaluator.resolve_boolean_value(key, default_value, evaluation_context)
7791

7892
def resolve_string_details(
7993
self,
8094
key: str,
8195
default_value: str,
8296
evaluation_context: typing.Optional[EvaluationContext] = None,
8397
) -> FlagResolutionDetails[str]:
84-
return self._resolve(key, default_value, evaluation_context)
98+
return self.evaluator.resolve_string_value(key, default_value, evaluation_context)
8599

86100
def resolve_float_details(
87101
self,
88102
key: str,
89103
default_value: float,
90104
evaluation_context: typing.Optional[EvaluationContext] = None,
91105
) -> FlagResolutionDetails[float]:
92-
result = self._resolve(key, default_value, evaluation_context)
93-
if isinstance(result.value, int):
94-
result.value = float(result.value)
95-
return result
106+
return self.evaluator.resolve_float_value(key, default_value, evaluation_context)
96107

97108
def resolve_integer_details(
98109
self,
99110
key: str,
100111
default_value: int,
101112
evaluation_context: typing.Optional[EvaluationContext] = None,
102113
) -> FlagResolutionDetails[int]:
103-
return self._resolve(key, default_value, evaluation_context)
114+
return self.evaluator.resolve_integer_value(key, default_value, evaluation_context)
104115

105116
def resolve_object_details(
106117
self,
@@ -112,75 +123,4 @@ def resolve_object_details(
112123
) -> FlagResolutionDetails[
113124
typing.Union[typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]]
114125
]:
115-
return self._resolve(key, default_value, evaluation_context)
116-
117-
def _resolve(
118-
self,
119-
key: str,
120-
default_value: T,
121-
evaluation_context: typing.Optional[EvaluationContext] = None,
122-
) -> FlagResolutionDetails[T]:
123-
flag = self.flag_store.get_flag(key)
124-
if not flag:
125-
raise FlagNotFoundError(f"Flag with key {key} not present in flag store.")
126-
127-
metadata = _merge_metadata(flag.metadata, self.flag_store.flag_set_metadata)
128-
129-
if flag.state == "DISABLED":
130-
return FlagResolutionDetails(
131-
default_value, flag_metadata=metadata, reason=Reason.DISABLED
132-
)
133-
134-
if not flag.targeting:
135-
return _default_resolve(flag, metadata, Reason.STATIC)
136-
137-
try:
138-
variant = targeting(flag.key, flag.targeting, evaluation_context)
139-
if variant is None:
140-
return _default_resolve(flag, metadata, Reason.DEFAULT)
141-
142-
# convert to string to support shorthand (boolean in python is with capital T hence the special case)
143-
if isinstance(variant, bool):
144-
variant = str(variant).lower()
145-
elif not isinstance(variant, str):
146-
variant = str(variant)
147-
148-
if variant not in flag.variants:
149-
raise GeneralError(
150-
f"Resolved variant {variant} not in variants config."
151-
)
152-
153-
except ReferenceError:
154-
raise ParseError(f"Invalid targeting {targeting}") from ReferenceError
155-
156-
variant, value = flag.get_variant(variant)
157-
if value is None:
158-
raise GeneralError(f"Resolved variant {variant} not in variants config.")
159-
160-
return FlagResolutionDetails(
161-
value,
162-
variant=variant,
163-
reason=Reason.TARGETING_MATCH,
164-
flag_metadata=metadata,
165-
)
166-
167-
168-
def _default_resolve(
169-
flag: Flag,
170-
metadata: typing.Mapping[str, typing.Union[float, int, str, bool]],
171-
reason: Reason,
172-
) -> FlagResolutionDetails:
173-
variant, value = flag.default
174-
if variant is None:
175-
return FlagResolutionDetails(
176-
value,
177-
variant=variant,
178-
reason=Reason.ERROR,
179-
error_code=ErrorCode.FLAG_NOT_FOUND,
180-
flag_metadata=metadata,
181-
)
182-
if variant not in flag.variants:
183-
raise GeneralError(f"Resolved variant {variant} not in variants config.")
184-
return FlagResolutionDetails(
185-
value, variant=variant, flag_metadata=metadata, reason=reason
186-
)
126+
return self.evaluator.resolve_object_value(key, default_value, evaluation_context)

0 commit comments

Comments
 (0)