openfeature-provider-flagd 0.2.5__py3-none-any.whl → 0.2.7__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/contrib/provider/flagd/provider.py +10 -3
- openfeature/contrib/provider/flagd/resolvers/grpc.py +82 -27
- openfeature/contrib/provider/flagd/resolvers/in_process.py +49 -20
- openfeature/contrib/provider/flagd/resolvers/process/connector/grpc_watcher.py +70 -38
- openfeature/contrib/provider/flagd/resolvers/process/flags.py +11 -14
- openfeature/contrib/provider/flagd/resolvers/process/targeting.py +9 -1
- openfeature/contrib/provider/flagd/resolvers/protocol.py +7 -3
- openfeature/contrib/provider/flagd/resolvers/types.py +7 -0
- openfeature/schemas/protobuf/flagd/evaluation/v1/evaluation_pb2.py +34 -34
- openfeature/schemas/protobuf/flagd/evaluation/v1/evaluation_pb2.pyi +41 -22
- openfeature/schemas/protobuf/flagd/evaluation/v1/evaluation_pb2_grpc.py +2 -2
- openfeature/schemas/protobuf/flagd/evaluation/v1/evaluation_pb2_grpc.pyi +194 -39
- openfeature/schemas/protobuf/flagd/sync/v1/sync_pb2.py +20 -14
- openfeature/schemas/protobuf/flagd/sync/v1/sync_pb2.pyi +33 -10
- openfeature/schemas/protobuf/flagd/sync/v1/sync_pb2_grpc.py +2 -2
- openfeature/schemas/protobuf/flagd/sync/v1/sync_pb2_grpc.pyi +94 -19
- openfeature/schemas/protobuf/schema/v1/schema_pb2.py +2 -2
- openfeature/schemas/protobuf/schema/v1/schema_pb2.pyi +25 -19
- openfeature/schemas/protobuf/schema/v1/schema_pb2_grpc.py +2 -2
- openfeature/schemas/protobuf/schema/v1/schema_pb2_grpc.pyi +194 -39
- openfeature/schemas/protobuf/sync/v1/sync_service_pb2.py +2 -2
- openfeature/schemas/protobuf/sync/v1/sync_service_pb2.pyi +9 -9
- openfeature/schemas/protobuf/sync/v1/sync_service_pb2_grpc.py +2 -2
- openfeature/schemas/protobuf/sync/v1/sync_service_pb2_grpc.pyi +69 -14
- openfeature_provider_flagd-0.2.7.dist-info/METADATA +208 -0
- openfeature_provider_flagd-0.2.7.dist-info/RECORD +37 -0
- {openfeature_provider_flagd-0.2.5.dist-info → openfeature_provider_flagd-0.2.7.dist-info}/WHEEL +1 -1
- openfeature_provider_flagd-0.2.7.dist-info/entry_points.txt +6 -0
- {openfeature_provider_flagd-0.2.5.dist-info → openfeature_provider_flagd-0.2.7.dist-info}/licenses/LICENSE +1 -1
- openfeature/.gitignore +0 -2
- openfeature_provider_flagd-0.2.5.dist-info/METADATA +0 -370
- openfeature_provider_flagd-0.2.5.dist-info/RECORD +0 -36
|
@@ -28,7 +28,7 @@ import grpc
|
|
|
28
28
|
|
|
29
29
|
from openfeature.evaluation_context import EvaluationContext
|
|
30
30
|
from openfeature.event import ProviderEventDetails
|
|
31
|
-
from openfeature.flag_evaluation import FlagResolutionDetails
|
|
31
|
+
from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType
|
|
32
32
|
from openfeature.hook import Hook
|
|
33
33
|
from openfeature.provider import AbstractProvider
|
|
34
34
|
from openfeature.provider.metadata import Metadata
|
|
@@ -75,6 +75,9 @@ class FlagdProvider(AbstractProvider):
|
|
|
75
75
|
:param deadline_ms: the maximum to wait before a request times out
|
|
76
76
|
:param timeout: the maximum time to wait before a request times out
|
|
77
77
|
:param retry_backoff_ms: the number of milliseconds to backoff
|
|
78
|
+
:param selector: filter flag configurations by source (in-process mode only)
|
|
79
|
+
Passed via both flagd-selector gRPC metadata header and request body
|
|
80
|
+
for backward compatibility with all flagd versions.
|
|
78
81
|
:param offline_flag_source_path: the path to the flag source file
|
|
79
82
|
:param stream_deadline_ms: the maximum time to wait before a request times out
|
|
80
83
|
:param keep_alive_time: the number of milliseconds to keep alive
|
|
@@ -199,9 +202,13 @@ class FlagdProvider(AbstractProvider):
|
|
|
199
202
|
def resolve_object_details(
|
|
200
203
|
self,
|
|
201
204
|
flag_key: str,
|
|
202
|
-
default_value: typing.Union[
|
|
205
|
+
default_value: typing.Union[
|
|
206
|
+
typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]
|
|
207
|
+
],
|
|
203
208
|
evaluation_context: typing.Optional[EvaluationContext] = None,
|
|
204
|
-
) -> FlagResolutionDetails[
|
|
209
|
+
) -> FlagResolutionDetails[
|
|
210
|
+
typing.Union[typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]]
|
|
211
|
+
]:
|
|
205
212
|
return self.resolver.resolve_object_details(
|
|
206
213
|
flag_key, default_value, evaluation_context
|
|
207
214
|
)
|
|
@@ -21,7 +21,7 @@ from openfeature.exception import (
|
|
|
21
21
|
ProviderNotReadyError,
|
|
22
22
|
TypeMismatchError,
|
|
23
23
|
)
|
|
24
|
-
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
|
|
24
|
+
from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType, Reason
|
|
25
25
|
from openfeature.schemas.protobuf.flagd.evaluation.v1 import (
|
|
26
26
|
evaluation_pb2,
|
|
27
27
|
evaluation_pb2_grpc,
|
|
@@ -29,6 +29,7 @@ from openfeature.schemas.protobuf.flagd.evaluation.v1 import (
|
|
|
29
29
|
|
|
30
30
|
from ..config import CacheType, Config
|
|
31
31
|
from ..flag_type import FlagType
|
|
32
|
+
from .types import GrpcMultiCallableArgs
|
|
32
33
|
|
|
33
34
|
if typing.TYPE_CHECKING:
|
|
34
35
|
from google.protobuf.message import Message
|
|
@@ -71,8 +72,6 @@ class GrpcResolver:
|
|
|
71
72
|
self.thread: typing.Optional[threading.Thread] = None
|
|
72
73
|
self.timer: typing.Optional[threading.Timer] = None
|
|
73
74
|
|
|
74
|
-
self.start_time = time.time()
|
|
75
|
-
|
|
76
75
|
def _generate_channel(self, config: Config) -> grpc.Channel:
|
|
77
76
|
target = f"{config.host}:{config.port}"
|
|
78
77
|
# Create the channel with the service config
|
|
@@ -121,15 +120,16 @@ class GrpcResolver:
|
|
|
121
120
|
),
|
|
122
121
|
]
|
|
123
122
|
if config.tls:
|
|
124
|
-
|
|
125
|
-
"options": options,
|
|
126
|
-
"credentials": grpc.ssl_channel_credentials(),
|
|
127
|
-
}
|
|
123
|
+
credentials = grpc.ssl_channel_credentials()
|
|
128
124
|
if config.cert_path:
|
|
129
125
|
with open(config.cert_path, "rb") as f:
|
|
130
|
-
|
|
126
|
+
credentials = grpc.ssl_channel_credentials(f.read())
|
|
131
127
|
|
|
132
|
-
channel = grpc.secure_channel(
|
|
128
|
+
channel = grpc.secure_channel(
|
|
129
|
+
target,
|
|
130
|
+
credentials=credentials,
|
|
131
|
+
options=options,
|
|
132
|
+
)
|
|
133
133
|
|
|
134
134
|
else:
|
|
135
135
|
channel = grpc.insecure_channel(
|
|
@@ -161,8 +161,8 @@ class GrpcResolver:
|
|
|
161
161
|
)
|
|
162
162
|
self.monitor_thread.start()
|
|
163
163
|
## block until ready or deadline reached
|
|
164
|
-
timeout = self.deadline + time.
|
|
165
|
-
while not self.connected and time.
|
|
164
|
+
timeout = self.deadline + time.monotonic()
|
|
165
|
+
while not self.connected and time.monotonic() < timeout:
|
|
166
166
|
time.sleep(0.05)
|
|
167
167
|
logger.debug("Finished blocking gRPC state initialization")
|
|
168
168
|
|
|
@@ -199,7 +199,6 @@ class GrpcResolver:
|
|
|
199
199
|
message="gRPC sync disconnected, reconnecting",
|
|
200
200
|
)
|
|
201
201
|
)
|
|
202
|
-
self.start_time = time.time()
|
|
203
202
|
# adding a timer, so we can emit the error event after time
|
|
204
203
|
self.timer = threading.Timer(self.retry_grace_period, self.emit_error)
|
|
205
204
|
|
|
@@ -220,20 +219,16 @@ class GrpcResolver:
|
|
|
220
219
|
|
|
221
220
|
def listen(self) -> None:
|
|
222
221
|
logger.debug("gRPC starting listener thread")
|
|
223
|
-
call_args =
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
else {}
|
|
227
|
-
)
|
|
222
|
+
call_args: GrpcMultiCallableArgs = {"wait_for_ready": True}
|
|
223
|
+
if self.streamline_deadline_seconds > 0:
|
|
224
|
+
call_args["timeout"] = self.streamline_deadline_seconds
|
|
228
225
|
request = evaluation_pb2.EventStreamRequest()
|
|
229
226
|
|
|
230
227
|
# defining a never ending loop to recreate the stream
|
|
231
228
|
while self.active:
|
|
232
229
|
try:
|
|
233
230
|
logger.debug("Setting up gRPC sync flags connection")
|
|
234
|
-
for message in self.stub.EventStream(
|
|
235
|
-
request, wait_for_ready=True, **call_args
|
|
236
|
-
):
|
|
231
|
+
for message in self.stub.EventStream(request, **call_args):
|
|
237
232
|
if message.type == "provider_ready":
|
|
238
233
|
self.emit_provider_ready(
|
|
239
234
|
ProviderEventDetails(
|
|
@@ -300,25 +295,81 @@ class GrpcResolver:
|
|
|
300
295
|
def resolve_object_details(
|
|
301
296
|
self,
|
|
302
297
|
key: str,
|
|
303
|
-
default_value: typing.Union[
|
|
298
|
+
default_value: typing.Union[
|
|
299
|
+
typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]
|
|
300
|
+
],
|
|
304
301
|
evaluation_context: typing.Optional[EvaluationContext] = None,
|
|
305
|
-
) -> FlagResolutionDetails[
|
|
302
|
+
) -> FlagResolutionDetails[
|
|
303
|
+
typing.Union[typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]]
|
|
304
|
+
]:
|
|
306
305
|
return self._resolve(key, FlagType.OBJECT, default_value, evaluation_context)
|
|
307
306
|
|
|
307
|
+
@typing.overload
|
|
308
|
+
def _resolve(
|
|
309
|
+
self,
|
|
310
|
+
flag_key: str,
|
|
311
|
+
flag_type: FlagType,
|
|
312
|
+
default_value: bool,
|
|
313
|
+
evaluation_context: typing.Optional[EvaluationContext],
|
|
314
|
+
) -> FlagResolutionDetails[bool]: ...
|
|
315
|
+
|
|
316
|
+
@typing.overload
|
|
317
|
+
def _resolve(
|
|
318
|
+
self,
|
|
319
|
+
flag_key: str,
|
|
320
|
+
flag_type: FlagType,
|
|
321
|
+
default_value: int,
|
|
322
|
+
evaluation_context: typing.Optional[EvaluationContext],
|
|
323
|
+
) -> FlagResolutionDetails[int]: ...
|
|
324
|
+
|
|
325
|
+
@typing.overload
|
|
326
|
+
def _resolve(
|
|
327
|
+
self,
|
|
328
|
+
flag_key: str,
|
|
329
|
+
flag_type: FlagType,
|
|
330
|
+
default_value: float,
|
|
331
|
+
evaluation_context: typing.Optional[EvaluationContext],
|
|
332
|
+
) -> FlagResolutionDetails[float]: ...
|
|
333
|
+
|
|
334
|
+
@typing.overload
|
|
335
|
+
def _resolve(
|
|
336
|
+
self,
|
|
337
|
+
flag_key: str,
|
|
338
|
+
flag_type: FlagType,
|
|
339
|
+
default_value: str,
|
|
340
|
+
evaluation_context: typing.Optional[EvaluationContext],
|
|
341
|
+
) -> FlagResolutionDetails[str]: ...
|
|
342
|
+
|
|
343
|
+
@typing.overload
|
|
344
|
+
def _resolve(
|
|
345
|
+
self,
|
|
346
|
+
flag_key: str,
|
|
347
|
+
flag_type: FlagType,
|
|
348
|
+
default_value: typing.Union[
|
|
349
|
+
typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]
|
|
350
|
+
],
|
|
351
|
+
evaluation_context: typing.Optional[EvaluationContext],
|
|
352
|
+
) -> FlagResolutionDetails[
|
|
353
|
+
typing.Union[typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]]
|
|
354
|
+
]: ...
|
|
355
|
+
|
|
308
356
|
def _resolve( # noqa: PLR0915 C901
|
|
309
357
|
self,
|
|
310
358
|
flag_key: str,
|
|
311
359
|
flag_type: FlagType,
|
|
312
|
-
default_value:
|
|
360
|
+
default_value: FlagValueType,
|
|
313
361
|
evaluation_context: typing.Optional[EvaluationContext],
|
|
314
|
-
) -> FlagResolutionDetails[
|
|
362
|
+
) -> FlagResolutionDetails[FlagValueType]:
|
|
315
363
|
if self.cache is not None and flag_key in self.cache:
|
|
316
|
-
cached_flag: FlagResolutionDetails[
|
|
364
|
+
cached_flag: FlagResolutionDetails[FlagValueType] = self.cache[flag_key]
|
|
317
365
|
cached_flag.reason = Reason.CACHED
|
|
318
366
|
return cached_flag
|
|
319
367
|
|
|
320
368
|
context = self._convert_context(evaluation_context)
|
|
321
|
-
call_args = {
|
|
369
|
+
call_args: GrpcMultiCallableArgs = {
|
|
370
|
+
"timeout": self.deadline,
|
|
371
|
+
"wait_for_ready": True,
|
|
372
|
+
}
|
|
322
373
|
try:
|
|
323
374
|
request: Message
|
|
324
375
|
if flag_type == FlagType.BOOLEAN:
|
|
@@ -387,7 +438,11 @@ class GrpcResolver:
|
|
|
387
438
|
if evaluation_context:
|
|
388
439
|
try:
|
|
389
440
|
s["targetingKey"] = evaluation_context.targeting_key
|
|
390
|
-
s.update(
|
|
441
|
+
s.update(
|
|
442
|
+
typing.cast(
|
|
443
|
+
"typing.Mapping[str, typing.Any]", evaluation_context.attributes
|
|
444
|
+
)
|
|
445
|
+
)
|
|
391
446
|
except ValueError as exc:
|
|
392
447
|
message = (
|
|
393
448
|
"could not serialize evaluation context to google.protobuf.Struct"
|
|
@@ -5,13 +5,13 @@ from openfeature.contrib.provider.flagd.resolvers.process.connector.file_watcher
|
|
|
5
5
|
)
|
|
6
6
|
from openfeature.evaluation_context import EvaluationContext
|
|
7
7
|
from openfeature.event import ProviderEventDetails
|
|
8
|
-
from openfeature.exception import FlagNotFoundError, ParseError
|
|
9
|
-
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
|
|
8
|
+
from openfeature.exception import ErrorCode, FlagNotFoundError, GeneralError, ParseError
|
|
9
|
+
from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType, Reason
|
|
10
10
|
|
|
11
11
|
from ..config import Config
|
|
12
12
|
from .process.connector import FlagStateConnector
|
|
13
13
|
from .process.connector.grpc_watcher import GrpcWatcher
|
|
14
|
-
from .process.flags import FlagStore
|
|
14
|
+
from .process.flags import Flag, FlagStore
|
|
15
15
|
from .process.targeting import targeting
|
|
16
16
|
|
|
17
17
|
T = typing.TypeVar("T")
|
|
@@ -105,9 +105,13 @@ class InProcessResolver:
|
|
|
105
105
|
def resolve_object_details(
|
|
106
106
|
self,
|
|
107
107
|
key: str,
|
|
108
|
-
default_value: typing.Union[
|
|
108
|
+
default_value: typing.Union[
|
|
109
|
+
typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]
|
|
110
|
+
],
|
|
109
111
|
evaluation_context: typing.Optional[EvaluationContext] = None,
|
|
110
|
-
) -> FlagResolutionDetails[
|
|
112
|
+
) -> FlagResolutionDetails[
|
|
113
|
+
typing.Union[typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]]
|
|
114
|
+
]:
|
|
111
115
|
return self._resolve(key, default_value, evaluation_context)
|
|
112
116
|
|
|
113
117
|
def _resolve(
|
|
@@ -128,26 +132,30 @@ class InProcessResolver:
|
|
|
128
132
|
)
|
|
129
133
|
|
|
130
134
|
if not flag.targeting:
|
|
131
|
-
|
|
132
|
-
return FlagResolutionDetails(
|
|
133
|
-
value, variant=variant, flag_metadata=metadata, reason=Reason.STATIC
|
|
134
|
-
)
|
|
135
|
+
return _default_resolve(flag, metadata, Reason.STATIC)
|
|
135
136
|
|
|
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)
|
|
137
141
|
|
|
138
|
-
|
|
139
|
-
variant,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
147
155
|
|
|
148
156
|
variant, value = flag.get_variant(variant)
|
|
149
157
|
if value is None:
|
|
150
|
-
raise
|
|
158
|
+
raise GeneralError(f"Resolved variant {variant} not in variants config.")
|
|
151
159
|
|
|
152
160
|
return FlagResolutionDetails(
|
|
153
161
|
value,
|
|
@@ -155,3 +163,24 @@ class InProcessResolver:
|
|
|
155
163
|
reason=Reason.TARGETING_MATCH,
|
|
156
164
|
flag_metadata=metadata,
|
|
157
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
|
+
)
|
|
@@ -6,7 +6,7 @@ import typing
|
|
|
6
6
|
|
|
7
7
|
import grpc
|
|
8
8
|
from google.protobuf.json_format import MessageToDict
|
|
9
|
-
from
|
|
9
|
+
from grpc import StatusCode
|
|
10
10
|
|
|
11
11
|
from openfeature.evaluation_context import EvaluationContext
|
|
12
12
|
from openfeature.event import ProviderEventDetails
|
|
@@ -17,6 +17,7 @@ from openfeature.schemas.protobuf.flagd.sync.v1 import (
|
|
|
17
17
|
)
|
|
18
18
|
|
|
19
19
|
from ....config import Config
|
|
20
|
+
from ...types import GrpcMultiCallableArgs
|
|
20
21
|
from ..connector import FlagStateConnector
|
|
21
22
|
from ..flags import FlagStore
|
|
22
23
|
|
|
@@ -52,8 +53,6 @@ class GrpcWatcher(FlagStateConnector):
|
|
|
52
53
|
self.thread: typing.Optional[threading.Thread] = None
|
|
53
54
|
self.timer: typing.Optional[threading.Timer] = None
|
|
54
55
|
|
|
55
|
-
self.start_time = time.time()
|
|
56
|
-
|
|
57
56
|
def _generate_channel(self, config: Config) -> grpc.Channel:
|
|
58
57
|
target = f"{config.host}:{config.port}"
|
|
59
58
|
# Create the channel with the service config
|
|
@@ -105,22 +104,23 @@ class GrpcWatcher(FlagStateConnector):
|
|
|
105
104
|
options.append(("grpc.default_authority", config.default_authority))
|
|
106
105
|
|
|
107
106
|
if config.channel_credentials is not None:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
107
|
+
channel = grpc.secure_channel(
|
|
108
|
+
target,
|
|
109
|
+
credentials=config.channel_credentials,
|
|
110
|
+
options=options,
|
|
111
|
+
)
|
|
113
112
|
|
|
114
113
|
elif config.tls:
|
|
115
|
-
|
|
116
|
-
"options": options,
|
|
117
|
-
"credentials": grpc.ssl_channel_credentials(),
|
|
118
|
-
}
|
|
114
|
+
credentials = grpc.ssl_channel_credentials()
|
|
119
115
|
if config.cert_path:
|
|
120
116
|
with open(config.cert_path, "rb") as f:
|
|
121
|
-
|
|
117
|
+
credentials = grpc.ssl_channel_credentials(f.read())
|
|
122
118
|
|
|
123
|
-
channel = grpc.secure_channel(
|
|
119
|
+
channel = grpc.secure_channel(
|
|
120
|
+
target,
|
|
121
|
+
credentials=credentials,
|
|
122
|
+
options=options,
|
|
123
|
+
)
|
|
124
124
|
|
|
125
125
|
else:
|
|
126
126
|
channel = grpc.insecure_channel(
|
|
@@ -142,8 +142,8 @@ class GrpcWatcher(FlagStateConnector):
|
|
|
142
142
|
)
|
|
143
143
|
self.monitor_thread.start()
|
|
144
144
|
## block until ready or deadline reached
|
|
145
|
-
timeout = self.deadline + time.
|
|
146
|
-
while not self.connected and time.
|
|
145
|
+
timeout = self.deadline + time.monotonic()
|
|
146
|
+
while not self.connected and time.monotonic() < timeout:
|
|
147
147
|
time.sleep(0.05)
|
|
148
148
|
logger.debug("Finished blocking gRPC state initialization")
|
|
149
149
|
|
|
@@ -180,7 +180,6 @@ class GrpcWatcher(FlagStateConnector):
|
|
|
180
180
|
message="gRPC sync disconnected, reconnecting",
|
|
181
181
|
)
|
|
182
182
|
)
|
|
183
|
-
self.start_time = time.time()
|
|
184
183
|
# adding a timer, so we can emit the error event after time
|
|
185
184
|
self.timer = threading.Timer(self.retry_grace_period, self.emit_error)
|
|
186
185
|
|
|
@@ -203,6 +202,8 @@ class GrpcWatcher(FlagStateConnector):
|
|
|
203
202
|
|
|
204
203
|
def _create_request_args(self) -> dict:
|
|
205
204
|
request_args = {}
|
|
205
|
+
# Pass selector in both request body (legacy) and metadata header (new) for backward compatibility
|
|
206
|
+
# This ensures compatibility with both older and newer flagd versions
|
|
206
207
|
if self.selector is not None:
|
|
207
208
|
request_args["selector"] = self.selector
|
|
208
209
|
if self.provider_id is not None:
|
|
@@ -210,47 +211,68 @@ class GrpcWatcher(FlagStateConnector):
|
|
|
210
211
|
|
|
211
212
|
return request_args
|
|
212
213
|
|
|
214
|
+
def _create_metadata(self) -> typing.Optional[tuple[tuple[str, str]]]:
|
|
215
|
+
"""Create gRPC metadata headers for the request.
|
|
216
|
+
|
|
217
|
+
Returns gRPC metadata as a tuples of tuples containing header key-value pairs.
|
|
218
|
+
The selector is passed via the 'flagd-selector' header per flagd v0.11.0+ specification,
|
|
219
|
+
while also being included in the request body for backward compatibility with older flagd versions.
|
|
220
|
+
"""
|
|
221
|
+
if self.selector is None:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
return (("flagd-selector", self.selector),)
|
|
225
|
+
|
|
226
|
+
def _fetch_metadata(self) -> typing.Optional[sync_pb2.GetMetadataResponse]:
|
|
227
|
+
if self.config.sync_metadata_disabled:
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
context_values_request = sync_pb2.GetMetadataRequest()
|
|
231
|
+
try:
|
|
232
|
+
context_values_response: sync_pb2.GetMetadataResponse = (
|
|
233
|
+
self.stub.GetMetadata(context_values_request, wait_for_ready=True)
|
|
234
|
+
)
|
|
235
|
+
return context_values_response
|
|
236
|
+
except grpc.RpcError as e:
|
|
237
|
+
if e.code() == StatusCode.UNIMPLEMENTED:
|
|
238
|
+
logger.debug(f"Error getting sync metadata: {e}")
|
|
239
|
+
return None
|
|
240
|
+
else:
|
|
241
|
+
raise e
|
|
242
|
+
|
|
213
243
|
def listen(self) -> None:
|
|
214
|
-
call_args = (
|
|
215
|
-
|
|
216
|
-
if self.streamline_deadline_seconds > 0
|
|
217
|
-
else {}
|
|
218
|
-
)
|
|
244
|
+
call_args = self.generate_grpc_call_args()
|
|
245
|
+
|
|
219
246
|
request_args = self._create_request_args()
|
|
220
247
|
|
|
221
248
|
while self.active:
|
|
222
249
|
try:
|
|
223
|
-
context_values_response
|
|
224
|
-
if self.config.sync_metadata_disabled:
|
|
225
|
-
context_values_response = sync_pb2.GetMetadataResponse(
|
|
226
|
-
metadata=Struct()
|
|
227
|
-
)
|
|
228
|
-
else:
|
|
229
|
-
context_values_request = sync_pb2.GetMetadataRequest()
|
|
230
|
-
context_values_response = self.stub.GetMetadata(
|
|
231
|
-
context_values_request, wait_for_ready=True
|
|
232
|
-
)
|
|
233
|
-
|
|
234
|
-
context_values = MessageToDict(context_values_response)
|
|
250
|
+
context_values_response = self._fetch_metadata()
|
|
235
251
|
|
|
236
252
|
request = sync_pb2.SyncFlagsRequest(**request_args)
|
|
237
253
|
|
|
238
254
|
logger.debug("Setting up gRPC sync flags connection")
|
|
239
|
-
for flag_rsp in self.stub.SyncFlags(
|
|
240
|
-
request, wait_for_ready=True, **call_args
|
|
241
|
-
):
|
|
255
|
+
for flag_rsp in self.stub.SyncFlags(request, **call_args):
|
|
242
256
|
flag_str = flag_rsp.flag_configuration
|
|
243
257
|
logger.debug(
|
|
244
258
|
f"Received flag configuration - {abs(hash(flag_str)) % (10**8)}"
|
|
245
259
|
)
|
|
246
260
|
self.flag_store.update(json.loads(flag_str))
|
|
247
261
|
|
|
262
|
+
context_values = {}
|
|
263
|
+
if flag_rsp.sync_context:
|
|
264
|
+
context_values = MessageToDict(flag_rsp.sync_context)
|
|
265
|
+
elif context_values_response:
|
|
266
|
+
context_values = MessageToDict(context_values_response)[
|
|
267
|
+
"metadata"
|
|
268
|
+
]
|
|
269
|
+
|
|
248
270
|
if not self.connected:
|
|
249
271
|
self.emit_provider_ready(
|
|
250
272
|
ProviderEventDetails(
|
|
251
273
|
message="gRPC sync connection established"
|
|
252
274
|
),
|
|
253
|
-
context_values
|
|
275
|
+
context_values,
|
|
254
276
|
)
|
|
255
277
|
self.connected = True
|
|
256
278
|
|
|
@@ -267,3 +289,13 @@ class GrpcWatcher(FlagStateConnector):
|
|
|
267
289
|
logger.exception(
|
|
268
290
|
f"Could not parse flag data using flagd syntax: {flag_str=}"
|
|
269
291
|
)
|
|
292
|
+
|
|
293
|
+
def generate_grpc_call_args(self) -> GrpcMultiCallableArgs:
|
|
294
|
+
call_args: GrpcMultiCallableArgs = {"wait_for_ready": True}
|
|
295
|
+
if self.streamline_deadline_seconds > 0:
|
|
296
|
+
call_args["timeout"] = self.streamline_deadline_seconds
|
|
297
|
+
# Add selector via gRPC metadata header (flagd v0.11.0+ preferred approach)
|
|
298
|
+
metadata = self._create_metadata()
|
|
299
|
+
if metadata is not None:
|
|
300
|
+
call_args["metadata"] = metadata
|
|
301
|
+
return call_args
|
|
@@ -72,30 +72,22 @@ class Flag:
|
|
|
72
72
|
key: str
|
|
73
73
|
state: str
|
|
74
74
|
variants: typing.Mapping[str, typing.Any]
|
|
75
|
-
default_variant: typing.Union[bool, str]
|
|
75
|
+
default_variant: typing.Optional[typing.Union[bool, str]] = None
|
|
76
76
|
targeting: typing.Optional[dict] = None
|
|
77
77
|
metadata: typing.Optional[
|
|
78
78
|
typing.Mapping[str, typing.Union[float, int, str, bool]]
|
|
79
79
|
] = None
|
|
80
80
|
|
|
81
81
|
def __post_init__(self) -> None:
|
|
82
|
-
if not self.state or not
|
|
82
|
+
if not self.state or not (self.state == "ENABLED" or self.state == "DISABLED"):
|
|
83
83
|
raise ParseError("Incorrect 'state' value provided in flag config")
|
|
84
84
|
|
|
85
85
|
if not self.variants or not isinstance(self.variants, dict):
|
|
86
86
|
raise ParseError("Incorrect 'variants' value provided in flag config")
|
|
87
87
|
|
|
88
|
-
if
|
|
89
|
-
self.default_variant, (str, bool)
|
|
90
|
-
):
|
|
88
|
+
if self.default_variant and not isinstance(self.default_variant, (str, bool)):
|
|
91
89
|
raise ParseError("Incorrect 'defaultVariant' value provided in flag config")
|
|
92
90
|
|
|
93
|
-
if self.targeting and not isinstance(self.targeting, dict):
|
|
94
|
-
raise ParseError("Incorrect 'targeting' value provided in flag config")
|
|
95
|
-
|
|
96
|
-
if self.default_variant not in self.variants:
|
|
97
|
-
raise ParseError("Default variant does not match set of variants")
|
|
98
|
-
|
|
99
91
|
if self.metadata:
|
|
100
92
|
if not isinstance(self.metadata, dict):
|
|
101
93
|
raise ParseError("Flag metadata is not a valid json object")
|
|
@@ -106,6 +98,8 @@ class Flag:
|
|
|
106
98
|
def from_dict(cls, key: str, data: dict) -> "Flag":
|
|
107
99
|
if "defaultVariant" in data:
|
|
108
100
|
data["default_variant"] = data["defaultVariant"]
|
|
101
|
+
if data["default_variant"] == "":
|
|
102
|
+
data["default_variant"] = None
|
|
109
103
|
del data["defaultVariant"]
|
|
110
104
|
|
|
111
105
|
data.pop("source", None)
|
|
@@ -119,13 +113,16 @@ class Flag:
|
|
|
119
113
|
raise ParseError from err
|
|
120
114
|
|
|
121
115
|
@property
|
|
122
|
-
def default(self) -> tuple[str, typing.Any]:
|
|
116
|
+
def default(self) -> tuple[typing.Optional[str], typing.Any]:
|
|
123
117
|
return self.get_variant(self.default_variant)
|
|
124
118
|
|
|
125
119
|
def get_variant(
|
|
126
|
-
self, variant_key: typing.Union[str, bool]
|
|
127
|
-
) -> tuple[str, typing.Any]:
|
|
120
|
+
self, variant_key: typing.Union[str, bool, None]
|
|
121
|
+
) -> tuple[typing.Optional[str], typing.Any]:
|
|
128
122
|
if isinstance(variant_key, bool):
|
|
129
123
|
variant_key = str(variant_key).lower()
|
|
130
124
|
|
|
125
|
+
if not variant_key:
|
|
126
|
+
return None, None
|
|
127
|
+
|
|
131
128
|
return variant_key, self.variants.get(variant_key)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import time
|
|
2
4
|
import typing
|
|
3
5
|
|
|
@@ -5,6 +7,7 @@ from json_logic import builtins, jsonLogic
|
|
|
5
7
|
from json_logic.types import JsonValue
|
|
6
8
|
|
|
7
9
|
from openfeature.evaluation_context import EvaluationContext
|
|
10
|
+
from openfeature.exception import ParseError
|
|
8
11
|
|
|
9
12
|
from .custom_ops import (
|
|
10
13
|
ends_with,
|
|
@@ -27,7 +30,12 @@ def targeting(
|
|
|
27
30
|
targeting: dict,
|
|
28
31
|
evaluation_context: typing.Optional[EvaluationContext] = None,
|
|
29
32
|
) -> JsonValue:
|
|
30
|
-
|
|
33
|
+
if not isinstance(targeting, dict):
|
|
34
|
+
raise ParseError(f"Invalid 'targeting' value in flag: {targeting}")
|
|
35
|
+
|
|
36
|
+
json_logic_context: dict[str, typing.Any] = (
|
|
37
|
+
dict(evaluation_context.attributes) if evaluation_context else {}
|
|
38
|
+
)
|
|
31
39
|
json_logic_context["$flagd"] = {"flagKey": key, "timestamp": int(time.time())}
|
|
32
40
|
json_logic_context["targetingKey"] = (
|
|
33
41
|
evaluation_context.targeting_key if evaluation_context else None
|
|
@@ -3,7 +3,7 @@ import typing
|
|
|
3
3
|
from typing_extensions import Protocol
|
|
4
4
|
|
|
5
5
|
from openfeature.evaluation_context import EvaluationContext
|
|
6
|
-
from openfeature.flag_evaluation import FlagResolutionDetails
|
|
6
|
+
from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class AbstractResolver(Protocol):
|
|
@@ -42,6 +42,10 @@ class AbstractResolver(Protocol):
|
|
|
42
42
|
def resolve_object_details(
|
|
43
43
|
self,
|
|
44
44
|
key: str,
|
|
45
|
-
default_value: typing.Union[
|
|
45
|
+
default_value: typing.Union[
|
|
46
|
+
typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]
|
|
47
|
+
],
|
|
46
48
|
evaluation_context: typing.Optional[EvaluationContext] = None,
|
|
47
|
-
) -> FlagResolutionDetails[
|
|
49
|
+
) -> FlagResolutionDetails[
|
|
50
|
+
typing.Union[typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]]
|
|
51
|
+
]: ...
|