openfeature-provider-flagd 0.1.4__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openfeature/.gitignore +2 -0
- openfeature/contrib/provider/flagd/config.py +202 -8
- openfeature/contrib/provider/flagd/provider.py +89 -97
- openfeature/contrib/provider/flagd/resolvers/__init__.py +5 -0
- openfeature/contrib/provider/flagd/resolvers/grpc.py +354 -0
- openfeature/contrib/provider/flagd/resolvers/in_process.py +131 -0
- openfeature/contrib/provider/flagd/resolvers/process/connector/__init__.py +11 -0
- openfeature/contrib/provider/flagd/resolvers/process/connector/file_watcher.py +106 -0
- openfeature/contrib/provider/flagd/resolvers/process/connector/grpc_watcher.py +192 -0
- openfeature/contrib/provider/flagd/resolvers/process/custom_ops.py +165 -0
- openfeature/contrib/provider/flagd/resolvers/process/flags.py +95 -0
- openfeature/contrib/provider/flagd/resolvers/process/targeting.py +35 -0
- openfeature/contrib/provider/flagd/resolvers/protocol.py +47 -0
- openfeature/schemas/protobuf/flagd/evaluation/v1/evaluation_pb2.py +72 -0
- openfeature/schemas/protobuf/flagd/evaluation/v1/evaluation_pb2.pyi +450 -0
- openfeature/schemas/protobuf/flagd/evaluation/v1/evaluation_pb2_grpc.py +358 -0
- openfeature/schemas/protobuf/flagd/evaluation/v1/evaluation_pb2_grpc.pyi +155 -0
- openfeature/schemas/protobuf/flagd/sync/v1/sync_pb2.py +50 -0
- openfeature/schemas/protobuf/flagd/sync/v1/sync_pb2.pyi +148 -0
- openfeature/schemas/protobuf/flagd/sync/v1/sync_pb2_grpc.py +186 -0
- openfeature/schemas/protobuf/flagd/sync/v1/sync_pb2_grpc.pyi +86 -0
- openfeature/schemas/protobuf/schema/v1/schema_pb2.py +72 -0
- openfeature/schemas/protobuf/schema/v1/schema_pb2.pyi +451 -0
- openfeature/schemas/protobuf/schema/v1/schema_pb2_grpc.py +358 -0
- openfeature/schemas/protobuf/schema/v1/schema_pb2_grpc.pyi +156 -0
- openfeature/schemas/protobuf/sync/v1/sync_service_pb2.py +47 -0
- openfeature/schemas/protobuf/sync/v1/sync_service_pb2.pyi +174 -0
- openfeature/schemas/protobuf/sync/v1/sync_service_pb2_grpc.py +143 -0
- openfeature/schemas/protobuf/sync/v1/sync_service_pb2_grpc.pyi +70 -0
- {openfeature_provider_flagd-0.1.4.dist-info → openfeature_provider_flagd-0.2.0.dist-info}/METADATA +132 -14
- openfeature_provider_flagd-0.2.0.dist-info/RECORD +35 -0
- {openfeature_provider_flagd-0.1.4.dist-info → openfeature_provider_flagd-0.2.0.dist-info}/WHEEL +1 -1
- {openfeature_provider_flagd-0.1.4.dist-info → openfeature_provider_flagd-0.2.0.dist-info}/licenses/LICENSE +1 -1
- openfeature/contrib/provider/flagd/proto/flagd/evaluation/v1/evaluation_pb2.py +0 -62
- openfeature/contrib/provider/flagd/proto/flagd/evaluation/v1/evaluation_pb2_grpc.py +0 -267
- openfeature/contrib/provider/flagd/proto/flagd/sync/v1/sync_pb2.py +0 -40
- openfeature/contrib/provider/flagd/proto/flagd/sync/v1/sync_pb2_grpc.py +0 -135
- openfeature/contrib/provider/flagd/proto/schema/v1/schema_pb2.py +0 -62
- openfeature/contrib/provider/flagd/proto/schema/v1/schema_pb2_grpc.py +0 -267
- openfeature/contrib/provider/flagd/proto/sync/v1/sync_service_pb2.py +0 -37
- openfeature/contrib/provider/flagd/proto/sync/v1/sync_service_pb2_grpc.py +0 -102
- openfeature_provider_flagd-0.1.4.dist-info/RECORD +0 -16
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
import typing
|
|
5
|
+
|
|
6
|
+
import grpc
|
|
7
|
+
from cachebox import BaseCacheImpl, LRUCache
|
|
8
|
+
from google.protobuf.json_format import MessageToDict
|
|
9
|
+
from google.protobuf.struct_pb2 import Struct
|
|
10
|
+
from grpc import ChannelConnectivity
|
|
11
|
+
|
|
12
|
+
from openfeature.evaluation_context import EvaluationContext
|
|
13
|
+
from openfeature.event import ProviderEventDetails
|
|
14
|
+
from openfeature.exception import (
|
|
15
|
+
ErrorCode,
|
|
16
|
+
FlagNotFoundError,
|
|
17
|
+
GeneralError,
|
|
18
|
+
InvalidContextError,
|
|
19
|
+
ParseError,
|
|
20
|
+
ProviderNotReadyError,
|
|
21
|
+
TypeMismatchError,
|
|
22
|
+
)
|
|
23
|
+
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
|
|
24
|
+
from openfeature.schemas.protobuf.flagd.evaluation.v1 import (
|
|
25
|
+
evaluation_pb2,
|
|
26
|
+
evaluation_pb2_grpc,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from ..config import CacheType, Config
|
|
30
|
+
from ..flag_type import FlagType
|
|
31
|
+
|
|
32
|
+
if typing.TYPE_CHECKING:
|
|
33
|
+
from google.protobuf.message import Message
|
|
34
|
+
|
|
35
|
+
T = typing.TypeVar("T")
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger("openfeature.contrib")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class GrpcResolver:
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
config: Config,
|
|
44
|
+
emit_provider_ready: typing.Callable[[ProviderEventDetails], None],
|
|
45
|
+
emit_provider_error: typing.Callable[[ProviderEventDetails], None],
|
|
46
|
+
emit_provider_stale: typing.Callable[[ProviderEventDetails], None],
|
|
47
|
+
emit_provider_configuration_changed: typing.Callable[
|
|
48
|
+
[ProviderEventDetails], None
|
|
49
|
+
],
|
|
50
|
+
):
|
|
51
|
+
self.active = False
|
|
52
|
+
self.config = config
|
|
53
|
+
self.emit_provider_ready = emit_provider_ready
|
|
54
|
+
self.emit_provider_error = emit_provider_error
|
|
55
|
+
self.emit_provider_stale = emit_provider_stale
|
|
56
|
+
self.emit_provider_configuration_changed = emit_provider_configuration_changed
|
|
57
|
+
self.cache: typing.Optional[BaseCacheImpl] = (
|
|
58
|
+
LRUCache(maxsize=self.config.max_cache_size)
|
|
59
|
+
if self.config.cache == CacheType.LRU
|
|
60
|
+
else None
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
self.retry_grace_period = config.retry_grace_period
|
|
64
|
+
self.streamline_deadline_seconds = config.stream_deadline_ms * 0.001
|
|
65
|
+
self.deadline = config.deadline_ms * 0.001
|
|
66
|
+
self.connected = False
|
|
67
|
+
self.channel = self._generate_channel(config)
|
|
68
|
+
self.stub = evaluation_pb2_grpc.ServiceStub(self.channel)
|
|
69
|
+
|
|
70
|
+
self.thread: typing.Optional[threading.Thread] = None
|
|
71
|
+
self.timer: typing.Optional[threading.Timer] = None
|
|
72
|
+
|
|
73
|
+
self.start_time = time.time()
|
|
74
|
+
|
|
75
|
+
def _generate_channel(self, config: Config) -> grpc.Channel:
|
|
76
|
+
target = f"{config.host}:{config.port}"
|
|
77
|
+
# Create the channel with the service config
|
|
78
|
+
options = [
|
|
79
|
+
("grpc.keepalive_time_ms", config.keep_alive_time),
|
|
80
|
+
("grpc.initial_reconnect_backoff_ms", config.retry_backoff_ms),
|
|
81
|
+
("grpc.max_reconnect_backoff_ms", config.retry_backoff_max_ms),
|
|
82
|
+
("grpc.min_reconnect_backoff_ms", config.deadline_ms),
|
|
83
|
+
]
|
|
84
|
+
if config.tls:
|
|
85
|
+
channel_args = {
|
|
86
|
+
"options": options,
|
|
87
|
+
"credentials": grpc.ssl_channel_credentials(),
|
|
88
|
+
}
|
|
89
|
+
if config.cert_path:
|
|
90
|
+
with open(config.cert_path, "rb") as f:
|
|
91
|
+
channel_args["credentials"] = grpc.ssl_channel_credentials(f.read())
|
|
92
|
+
|
|
93
|
+
channel = grpc.secure_channel(target, **channel_args)
|
|
94
|
+
|
|
95
|
+
else:
|
|
96
|
+
channel = grpc.insecure_channel(
|
|
97
|
+
target,
|
|
98
|
+
options=options,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return channel
|
|
102
|
+
|
|
103
|
+
def initialize(self, evaluation_context: EvaluationContext) -> None:
|
|
104
|
+
self.connect()
|
|
105
|
+
|
|
106
|
+
def shutdown(self) -> None:
|
|
107
|
+
self.active = False
|
|
108
|
+
self.channel.unsubscribe(self._state_change_callback)
|
|
109
|
+
self.channel.close()
|
|
110
|
+
if self.timer and self.timer.is_alive():
|
|
111
|
+
logger.debug("gRPC error timer cancelled due to shutdown")
|
|
112
|
+
self.timer.cancel()
|
|
113
|
+
if self.cache:
|
|
114
|
+
self.cache.clear()
|
|
115
|
+
|
|
116
|
+
def connect(self) -> None:
|
|
117
|
+
self.active = True
|
|
118
|
+
|
|
119
|
+
# Run monitoring in a separate thread
|
|
120
|
+
self.monitor_thread = threading.Thread(
|
|
121
|
+
target=self.monitor, daemon=True, name="FlagdGrpcServiceMonitorThread"
|
|
122
|
+
)
|
|
123
|
+
self.monitor_thread.start()
|
|
124
|
+
## block until ready or deadline reached
|
|
125
|
+
timeout = self.deadline + time.time()
|
|
126
|
+
while not self.connected and time.time() < timeout:
|
|
127
|
+
time.sleep(0.05)
|
|
128
|
+
logger.debug("Finished blocking gRPC state initialization")
|
|
129
|
+
|
|
130
|
+
if not self.connected:
|
|
131
|
+
raise ProviderNotReadyError(
|
|
132
|
+
"Blocking init finished before data synced. Consider increasing startup deadline to avoid inconsistent evaluations."
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def monitor(self) -> None:
|
|
136
|
+
self.channel.subscribe(self._state_change_callback, try_to_connect=True)
|
|
137
|
+
|
|
138
|
+
def _state_change_callback(self, new_state: ChannelConnectivity) -> None:
|
|
139
|
+
logger.debug(f"gRPC state change: {new_state}")
|
|
140
|
+
if new_state == ChannelConnectivity.READY:
|
|
141
|
+
if not self.thread or not self.thread.is_alive():
|
|
142
|
+
self.thread = threading.Thread(
|
|
143
|
+
target=self.listen,
|
|
144
|
+
daemon=True,
|
|
145
|
+
name="FlagdGrpcServiceWorkerThread",
|
|
146
|
+
)
|
|
147
|
+
self.thread.start()
|
|
148
|
+
|
|
149
|
+
if self.timer and self.timer.is_alive():
|
|
150
|
+
logger.debug("gRPC error timer expired")
|
|
151
|
+
self.timer.cancel()
|
|
152
|
+
|
|
153
|
+
elif new_state == ChannelConnectivity.TRANSIENT_FAILURE:
|
|
154
|
+
# this is the failed reconnect attempt so we are going into stale
|
|
155
|
+
self.emit_provider_stale(
|
|
156
|
+
ProviderEventDetails(
|
|
157
|
+
message="gRPC sync disconnected, reconnecting",
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
self.start_time = time.time()
|
|
161
|
+
# adding a timer, so we can emit the error event after time
|
|
162
|
+
self.timer = threading.Timer(self.retry_grace_period, self.emit_error)
|
|
163
|
+
|
|
164
|
+
logger.debug("gRPC error timer started")
|
|
165
|
+
self.timer.start()
|
|
166
|
+
self.connected = False
|
|
167
|
+
|
|
168
|
+
def emit_error(self) -> None:
|
|
169
|
+
logger.debug("gRPC error emitted")
|
|
170
|
+
if self.cache:
|
|
171
|
+
self.cache.clear()
|
|
172
|
+
self.emit_provider_error(
|
|
173
|
+
ProviderEventDetails(
|
|
174
|
+
message="gRPC sync disconnected, reconnecting",
|
|
175
|
+
error_code=ErrorCode.GENERAL,
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def listen(self) -> None:
|
|
180
|
+
logger.debug("gRPC starting listener thread")
|
|
181
|
+
call_args = (
|
|
182
|
+
{"timeout": self.streamline_deadline_seconds}
|
|
183
|
+
if self.streamline_deadline_seconds > 0
|
|
184
|
+
else {}
|
|
185
|
+
)
|
|
186
|
+
request = evaluation_pb2.EventStreamRequest()
|
|
187
|
+
|
|
188
|
+
# defining a never ending loop to recreate the stream
|
|
189
|
+
while self.active:
|
|
190
|
+
try:
|
|
191
|
+
logger.debug("Setting up gRPC sync flags connection")
|
|
192
|
+
for message in self.stub.EventStream(
|
|
193
|
+
request, wait_for_ready=True, **call_args
|
|
194
|
+
):
|
|
195
|
+
if message.type == "provider_ready":
|
|
196
|
+
self.emit_provider_ready(
|
|
197
|
+
ProviderEventDetails(
|
|
198
|
+
message="gRPC sync connection established"
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
self.connected = True
|
|
202
|
+
elif message.type == "configuration_change":
|
|
203
|
+
data = MessageToDict(message)["data"]
|
|
204
|
+
self.handle_changed_flags(data)
|
|
205
|
+
|
|
206
|
+
if not self.active:
|
|
207
|
+
logger.info("Terminating gRPC sync thread")
|
|
208
|
+
return
|
|
209
|
+
except grpc.RpcError as e: # noqa: PERF203
|
|
210
|
+
# although it seems like this error log is not interesting, without it, the retry is not working as expected
|
|
211
|
+
logger.debug(f"SyncFlags stream error, {e.code()=} {e.details()=}")
|
|
212
|
+
except ParseError:
|
|
213
|
+
logger.exception(
|
|
214
|
+
f"Could not parse flag data using flagd syntax: {message=}"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def handle_changed_flags(self, data: typing.Any) -> None:
|
|
218
|
+
changed_flags = list(data["flags"].keys())
|
|
219
|
+
|
|
220
|
+
if self.cache:
|
|
221
|
+
for flag in changed_flags:
|
|
222
|
+
self.cache.pop(flag)
|
|
223
|
+
|
|
224
|
+
self.emit_provider_configuration_changed(ProviderEventDetails(changed_flags))
|
|
225
|
+
|
|
226
|
+
def resolve_boolean_details(
|
|
227
|
+
self,
|
|
228
|
+
key: str,
|
|
229
|
+
default_value: bool,
|
|
230
|
+
evaluation_context: typing.Optional[EvaluationContext] = None,
|
|
231
|
+
) -> FlagResolutionDetails[bool]:
|
|
232
|
+
return self._resolve(key, FlagType.BOOLEAN, default_value, evaluation_context)
|
|
233
|
+
|
|
234
|
+
def resolve_string_details(
|
|
235
|
+
self,
|
|
236
|
+
key: str,
|
|
237
|
+
default_value: str,
|
|
238
|
+
evaluation_context: typing.Optional[EvaluationContext] = None,
|
|
239
|
+
) -> FlagResolutionDetails[str]:
|
|
240
|
+
return self._resolve(key, FlagType.STRING, default_value, evaluation_context)
|
|
241
|
+
|
|
242
|
+
def resolve_float_details(
|
|
243
|
+
self,
|
|
244
|
+
key: str,
|
|
245
|
+
default_value: float,
|
|
246
|
+
evaluation_context: typing.Optional[EvaluationContext] = None,
|
|
247
|
+
) -> FlagResolutionDetails[float]:
|
|
248
|
+
return self._resolve(key, FlagType.FLOAT, default_value, evaluation_context)
|
|
249
|
+
|
|
250
|
+
def resolve_integer_details(
|
|
251
|
+
self,
|
|
252
|
+
key: str,
|
|
253
|
+
default_value: int,
|
|
254
|
+
evaluation_context: typing.Optional[EvaluationContext] = None,
|
|
255
|
+
) -> FlagResolutionDetails[int]:
|
|
256
|
+
return self._resolve(key, FlagType.INTEGER, default_value, evaluation_context)
|
|
257
|
+
|
|
258
|
+
def resolve_object_details(
|
|
259
|
+
self,
|
|
260
|
+
key: str,
|
|
261
|
+
default_value: typing.Union[dict, list],
|
|
262
|
+
evaluation_context: typing.Optional[EvaluationContext] = None,
|
|
263
|
+
) -> FlagResolutionDetails[typing.Union[dict, list]]:
|
|
264
|
+
return self._resolve(key, FlagType.OBJECT, default_value, evaluation_context)
|
|
265
|
+
|
|
266
|
+
def _resolve( # noqa: PLR0915 C901
|
|
267
|
+
self,
|
|
268
|
+
flag_key: str,
|
|
269
|
+
flag_type: FlagType,
|
|
270
|
+
default_value: T,
|
|
271
|
+
evaluation_context: typing.Optional[EvaluationContext],
|
|
272
|
+
) -> FlagResolutionDetails[T]:
|
|
273
|
+
if self.cache is not None and flag_key in self.cache:
|
|
274
|
+
cached_flag: FlagResolutionDetails[T] = self.cache[flag_key]
|
|
275
|
+
cached_flag.reason = Reason.CACHED
|
|
276
|
+
return cached_flag
|
|
277
|
+
|
|
278
|
+
context = self._convert_context(evaluation_context)
|
|
279
|
+
call_args = {"timeout": self.deadline}
|
|
280
|
+
try:
|
|
281
|
+
request: Message
|
|
282
|
+
if flag_type == FlagType.BOOLEAN:
|
|
283
|
+
request = evaluation_pb2.ResolveBooleanRequest(
|
|
284
|
+
flag_key=flag_key, context=context
|
|
285
|
+
)
|
|
286
|
+
response = self.stub.ResolveBoolean(request, **call_args)
|
|
287
|
+
value = response.value
|
|
288
|
+
elif flag_type == FlagType.STRING:
|
|
289
|
+
request = evaluation_pb2.ResolveStringRequest(
|
|
290
|
+
flag_key=flag_key, context=context
|
|
291
|
+
)
|
|
292
|
+
response = self.stub.ResolveString(request, **call_args)
|
|
293
|
+
value = response.value
|
|
294
|
+
elif flag_type == FlagType.OBJECT:
|
|
295
|
+
request = evaluation_pb2.ResolveObjectRequest(
|
|
296
|
+
flag_key=flag_key, context=context
|
|
297
|
+
)
|
|
298
|
+
response = self.stub.ResolveObject(request, **call_args)
|
|
299
|
+
value = MessageToDict(response, preserving_proto_field_name=True)[
|
|
300
|
+
"value"
|
|
301
|
+
]
|
|
302
|
+
elif flag_type == FlagType.FLOAT:
|
|
303
|
+
request = evaluation_pb2.ResolveFloatRequest(
|
|
304
|
+
flag_key=flag_key, context=context
|
|
305
|
+
)
|
|
306
|
+
response = self.stub.ResolveFloat(request, **call_args)
|
|
307
|
+
value = response.value
|
|
308
|
+
elif flag_type == FlagType.INTEGER:
|
|
309
|
+
request = evaluation_pb2.ResolveIntRequest(
|
|
310
|
+
flag_key=flag_key, context=context
|
|
311
|
+
)
|
|
312
|
+
response = self.stub.ResolveInt(request, **call_args)
|
|
313
|
+
value = response.value
|
|
314
|
+
else:
|
|
315
|
+
raise ValueError(f"Unknown flag type: {flag_type}")
|
|
316
|
+
|
|
317
|
+
except grpc.RpcError as e:
|
|
318
|
+
code = e.code()
|
|
319
|
+
message = f"received grpc status code {code}"
|
|
320
|
+
|
|
321
|
+
if code == grpc.StatusCode.NOT_FOUND:
|
|
322
|
+
raise FlagNotFoundError(message) from e
|
|
323
|
+
elif code == grpc.StatusCode.INVALID_ARGUMENT:
|
|
324
|
+
raise TypeMismatchError(message) from e
|
|
325
|
+
elif code == grpc.StatusCode.DATA_LOSS:
|
|
326
|
+
raise ParseError(message) from e
|
|
327
|
+
raise GeneralError(message) from e
|
|
328
|
+
|
|
329
|
+
# Got a valid flag and valid type. Return it.
|
|
330
|
+
result = FlagResolutionDetails(
|
|
331
|
+
value=value,
|
|
332
|
+
reason=response.reason,
|
|
333
|
+
variant=response.variant,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
if response.reason == Reason.STATIC and self.cache is not None:
|
|
337
|
+
self.cache.insert(flag_key, result)
|
|
338
|
+
|
|
339
|
+
return result
|
|
340
|
+
|
|
341
|
+
def _convert_context(
|
|
342
|
+
self, evaluation_context: typing.Optional[EvaluationContext]
|
|
343
|
+
) -> Struct:
|
|
344
|
+
s = Struct()
|
|
345
|
+
if evaluation_context:
|
|
346
|
+
try:
|
|
347
|
+
s["targetingKey"] = evaluation_context.targeting_key
|
|
348
|
+
s.update(evaluation_context.attributes)
|
|
349
|
+
except ValueError as exc:
|
|
350
|
+
message = (
|
|
351
|
+
"could not serialize evaluation context to google.protobuf.Struct"
|
|
352
|
+
)
|
|
353
|
+
raise InvalidContextError(message) from exc
|
|
354
|
+
return s
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
from openfeature.contrib.provider.flagd.resolvers.process.connector.file_watcher import (
|
|
4
|
+
FileWatcher,
|
|
5
|
+
)
|
|
6
|
+
from openfeature.evaluation_context import EvaluationContext
|
|
7
|
+
from openfeature.event import ProviderEventDetails
|
|
8
|
+
from openfeature.exception import FlagNotFoundError, ParseError
|
|
9
|
+
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
|
|
10
|
+
|
|
11
|
+
from ..config import Config
|
|
12
|
+
from .process.connector import FlagStateConnector
|
|
13
|
+
from .process.connector.grpc_watcher import GrpcWatcher
|
|
14
|
+
from .process.flags import FlagStore
|
|
15
|
+
from .process.targeting import targeting
|
|
16
|
+
|
|
17
|
+
T = typing.TypeVar("T")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InProcessResolver:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
config: Config,
|
|
24
|
+
emit_provider_ready: typing.Callable[[ProviderEventDetails], None],
|
|
25
|
+
emit_provider_error: typing.Callable[[ProviderEventDetails], None],
|
|
26
|
+
emit_provider_stale: typing.Callable[[ProviderEventDetails], None],
|
|
27
|
+
emit_provider_configuration_changed: typing.Callable[
|
|
28
|
+
[ProviderEventDetails], None
|
|
29
|
+
],
|
|
30
|
+
):
|
|
31
|
+
self.config = config
|
|
32
|
+
self.flag_store = FlagStore(emit_provider_configuration_changed)
|
|
33
|
+
self.connector: FlagStateConnector = (
|
|
34
|
+
FileWatcher(
|
|
35
|
+
self.config, self.flag_store, emit_provider_ready, emit_provider_error
|
|
36
|
+
)
|
|
37
|
+
if self.config.offline_flag_source_path
|
|
38
|
+
else GrpcWatcher(
|
|
39
|
+
self.config,
|
|
40
|
+
self.flag_store,
|
|
41
|
+
emit_provider_ready,
|
|
42
|
+
emit_provider_error,
|
|
43
|
+
emit_provider_stale,
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def initialize(self, evaluation_context: EvaluationContext) -> None:
|
|
48
|
+
self.connector.initialize(evaluation_context)
|
|
49
|
+
|
|
50
|
+
def shutdown(self) -> None:
|
|
51
|
+
self.connector.shutdown()
|
|
52
|
+
|
|
53
|
+
def resolve_boolean_details(
|
|
54
|
+
self,
|
|
55
|
+
key: str,
|
|
56
|
+
default_value: bool,
|
|
57
|
+
evaluation_context: typing.Optional[EvaluationContext] = None,
|
|
58
|
+
) -> FlagResolutionDetails[bool]:
|
|
59
|
+
return self._resolve(key, default_value, evaluation_context)
|
|
60
|
+
|
|
61
|
+
def resolve_string_details(
|
|
62
|
+
self,
|
|
63
|
+
key: str,
|
|
64
|
+
default_value: str,
|
|
65
|
+
evaluation_context: typing.Optional[EvaluationContext] = None,
|
|
66
|
+
) -> FlagResolutionDetails[str]:
|
|
67
|
+
return self._resolve(key, default_value, evaluation_context)
|
|
68
|
+
|
|
69
|
+
def resolve_float_details(
|
|
70
|
+
self,
|
|
71
|
+
key: str,
|
|
72
|
+
default_value: float,
|
|
73
|
+
evaluation_context: typing.Optional[EvaluationContext] = None,
|
|
74
|
+
) -> FlagResolutionDetails[float]:
|
|
75
|
+
result = self._resolve(key, default_value, evaluation_context)
|
|
76
|
+
if isinstance(result.value, int):
|
|
77
|
+
result.value = float(result.value)
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
def resolve_integer_details(
|
|
81
|
+
self,
|
|
82
|
+
key: str,
|
|
83
|
+
default_value: int,
|
|
84
|
+
evaluation_context: typing.Optional[EvaluationContext] = None,
|
|
85
|
+
) -> FlagResolutionDetails[int]:
|
|
86
|
+
return self._resolve(key, default_value, evaluation_context)
|
|
87
|
+
|
|
88
|
+
def resolve_object_details(
|
|
89
|
+
self,
|
|
90
|
+
key: str,
|
|
91
|
+
default_value: typing.Union[dict, list],
|
|
92
|
+
evaluation_context: typing.Optional[EvaluationContext] = None,
|
|
93
|
+
) -> FlagResolutionDetails[typing.Union[dict, list]]:
|
|
94
|
+
return self._resolve(key, default_value, evaluation_context)
|
|
95
|
+
|
|
96
|
+
def _resolve(
|
|
97
|
+
self,
|
|
98
|
+
key: str,
|
|
99
|
+
default_value: T,
|
|
100
|
+
evaluation_context: typing.Optional[EvaluationContext] = None,
|
|
101
|
+
) -> FlagResolutionDetails[T]:
|
|
102
|
+
flag = self.flag_store.get_flag(key)
|
|
103
|
+
if not flag:
|
|
104
|
+
raise FlagNotFoundError(f"Flag with key {key} not present in flag store.")
|
|
105
|
+
|
|
106
|
+
if flag.state == "DISABLED":
|
|
107
|
+
return FlagResolutionDetails(default_value, reason=Reason.DISABLED)
|
|
108
|
+
|
|
109
|
+
if not flag.targeting:
|
|
110
|
+
variant, value = flag.default
|
|
111
|
+
return FlagResolutionDetails(value, variant=variant, reason=Reason.STATIC)
|
|
112
|
+
|
|
113
|
+
variant = targeting(flag.key, flag.targeting, evaluation_context)
|
|
114
|
+
|
|
115
|
+
if variant is None:
|
|
116
|
+
variant, value = flag.default
|
|
117
|
+
return FlagResolutionDetails(value, variant=variant, reason=Reason.DEFAULT)
|
|
118
|
+
if not isinstance(variant, (str, bool)):
|
|
119
|
+
raise ParseError(
|
|
120
|
+
"Parsed JSONLogic targeting did not return a string or bool"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
variant, value = flag.get_variant(variant)
|
|
124
|
+
if not value:
|
|
125
|
+
raise ParseError(f"Resolved variant {variant} not in variants config.")
|
|
126
|
+
|
|
127
|
+
return FlagResolutionDetails(
|
|
128
|
+
value,
|
|
129
|
+
variant=variant,
|
|
130
|
+
reason=Reason.TARGETING_MATCH,
|
|
131
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
from openfeature.evaluation_context import EvaluationContext
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FlagStateConnector(typing.Protocol):
|
|
7
|
+
def initialize(
|
|
8
|
+
self, evaluation_context: EvaluationContext
|
|
9
|
+
) -> None: ... # pragma: no cover
|
|
10
|
+
|
|
11
|
+
def shutdown(self) -> None: ... # pragma: no cover
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import typing
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from openfeature.contrib.provider.flagd.config import Config
|
|
11
|
+
from openfeature.contrib.provider.flagd.resolvers.process.connector import (
|
|
12
|
+
FlagStateConnector,
|
|
13
|
+
)
|
|
14
|
+
from openfeature.contrib.provider.flagd.resolvers.process.flags import FlagStore
|
|
15
|
+
from openfeature.evaluation_context import EvaluationContext
|
|
16
|
+
from openfeature.event import ProviderEventDetails
|
|
17
|
+
from openfeature.exception import ParseError, ProviderNotReadyError
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("openfeature.contrib")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FileWatcher(FlagStateConnector):
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
config: Config,
|
|
26
|
+
flag_store: FlagStore,
|
|
27
|
+
emit_provider_ready: typing.Callable[[ProviderEventDetails], None],
|
|
28
|
+
emit_provider_error: typing.Callable[[ProviderEventDetails], None],
|
|
29
|
+
):
|
|
30
|
+
if config.offline_flag_source_path is None:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"`config.offline_flag_source_path` parameter invalid: {config.offline_flag_source_path}"
|
|
33
|
+
)
|
|
34
|
+
else:
|
|
35
|
+
self.file_path = config.offline_flag_source_path
|
|
36
|
+
|
|
37
|
+
self.emit_provider_ready = emit_provider_ready
|
|
38
|
+
self.emit_provider_error = emit_provider_error
|
|
39
|
+
self.deadline_seconds = config.deadline_ms * 0.001
|
|
40
|
+
|
|
41
|
+
self.last_modified = 0.0
|
|
42
|
+
self.flag_store = flag_store
|
|
43
|
+
self.should_emit_ready_on_success = False
|
|
44
|
+
|
|
45
|
+
def initialize(self, evaluation_context: EvaluationContext) -> None:
|
|
46
|
+
self.active = True
|
|
47
|
+
self.thread = threading.Thread(
|
|
48
|
+
target=self.refresh_file, daemon=True, name="FlagdFileWatcherWorkerThread"
|
|
49
|
+
)
|
|
50
|
+
self.thread.start()
|
|
51
|
+
|
|
52
|
+
# Let this throw exceptions so that provider status is set correctly
|
|
53
|
+
try:
|
|
54
|
+
self.should_emit_ready_on_success = True
|
|
55
|
+
self._load_data()
|
|
56
|
+
except Exception as err:
|
|
57
|
+
raise ProviderNotReadyError from err
|
|
58
|
+
|
|
59
|
+
def shutdown(self) -> None:
|
|
60
|
+
self.active = False
|
|
61
|
+
|
|
62
|
+
def refresh_file(self) -> None:
|
|
63
|
+
while self.active:
|
|
64
|
+
time.sleep(self.deadline_seconds)
|
|
65
|
+
logger.debug("checking for new flag store contents from file")
|
|
66
|
+
self.safe_load_data()
|
|
67
|
+
|
|
68
|
+
def safe_load_data(self) -> None:
|
|
69
|
+
try:
|
|
70
|
+
last_modified = os.path.getmtime(self.file_path)
|
|
71
|
+
if last_modified > self.last_modified:
|
|
72
|
+
self._load_data(last_modified)
|
|
73
|
+
except FileNotFoundError:
|
|
74
|
+
self.handle_error("Provided file path not valid")
|
|
75
|
+
except json.JSONDecodeError:
|
|
76
|
+
self.handle_error("Could not parse JSON flag data from file")
|
|
77
|
+
except yaml.error.YAMLError:
|
|
78
|
+
self.handle_error("Could not parse YAML flag data from file")
|
|
79
|
+
except ParseError:
|
|
80
|
+
self.handle_error("Could not parse flag data using flagd syntax")
|
|
81
|
+
except Exception:
|
|
82
|
+
self.handle_error("Could not read flags from file")
|
|
83
|
+
|
|
84
|
+
def _load_data(self, modified_time: typing.Optional[float] = None) -> None:
|
|
85
|
+
with open(self.file_path) as file:
|
|
86
|
+
if self.file_path.endswith(".yaml"):
|
|
87
|
+
data = yaml.safe_load(file)
|
|
88
|
+
else:
|
|
89
|
+
data = json.load(file)
|
|
90
|
+
|
|
91
|
+
self.flag_store.update(data)
|
|
92
|
+
|
|
93
|
+
if self.should_emit_ready_on_success:
|
|
94
|
+
self.emit_provider_ready(
|
|
95
|
+
ProviderEventDetails(
|
|
96
|
+
message="Reloading file contents recovered from error state"
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
self.should_emit_ready_on_success = False
|
|
100
|
+
|
|
101
|
+
self.last_modified = modified_time or os.path.getmtime(self.file_path)
|
|
102
|
+
|
|
103
|
+
def handle_error(self, error_message: str) -> None:
|
|
104
|
+
logger.exception(error_message)
|
|
105
|
+
self.should_emit_ready_on_success = True
|
|
106
|
+
self.emit_provider_error(ProviderEventDetails(message=error_message))
|