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.
Files changed (32) hide show
  1. openfeature/contrib/provider/flagd/provider.py +10 -3
  2. openfeature/contrib/provider/flagd/resolvers/grpc.py +82 -27
  3. openfeature/contrib/provider/flagd/resolvers/in_process.py +49 -20
  4. openfeature/contrib/provider/flagd/resolvers/process/connector/grpc_watcher.py +70 -38
  5. openfeature/contrib/provider/flagd/resolvers/process/flags.py +11 -14
  6. openfeature/contrib/provider/flagd/resolvers/process/targeting.py +9 -1
  7. openfeature/contrib/provider/flagd/resolvers/protocol.py +7 -3
  8. openfeature/contrib/provider/flagd/resolvers/types.py +7 -0
  9. openfeature/schemas/protobuf/flagd/evaluation/v1/evaluation_pb2.py +34 -34
  10. openfeature/schemas/protobuf/flagd/evaluation/v1/evaluation_pb2.pyi +41 -22
  11. openfeature/schemas/protobuf/flagd/evaluation/v1/evaluation_pb2_grpc.py +2 -2
  12. openfeature/schemas/protobuf/flagd/evaluation/v1/evaluation_pb2_grpc.pyi +194 -39
  13. openfeature/schemas/protobuf/flagd/sync/v1/sync_pb2.py +20 -14
  14. openfeature/schemas/protobuf/flagd/sync/v1/sync_pb2.pyi +33 -10
  15. openfeature/schemas/protobuf/flagd/sync/v1/sync_pb2_grpc.py +2 -2
  16. openfeature/schemas/protobuf/flagd/sync/v1/sync_pb2_grpc.pyi +94 -19
  17. openfeature/schemas/protobuf/schema/v1/schema_pb2.py +2 -2
  18. openfeature/schemas/protobuf/schema/v1/schema_pb2.pyi +25 -19
  19. openfeature/schemas/protobuf/schema/v1/schema_pb2_grpc.py +2 -2
  20. openfeature/schemas/protobuf/schema/v1/schema_pb2_grpc.pyi +194 -39
  21. openfeature/schemas/protobuf/sync/v1/sync_service_pb2.py +2 -2
  22. openfeature/schemas/protobuf/sync/v1/sync_service_pb2.pyi +9 -9
  23. openfeature/schemas/protobuf/sync/v1/sync_service_pb2_grpc.py +2 -2
  24. openfeature/schemas/protobuf/sync/v1/sync_service_pb2_grpc.pyi +69 -14
  25. openfeature_provider_flagd-0.2.7.dist-info/METADATA +208 -0
  26. openfeature_provider_flagd-0.2.7.dist-info/RECORD +37 -0
  27. {openfeature_provider_flagd-0.2.5.dist-info → openfeature_provider_flagd-0.2.7.dist-info}/WHEEL +1 -1
  28. openfeature_provider_flagd-0.2.7.dist-info/entry_points.txt +6 -0
  29. {openfeature_provider_flagd-0.2.5.dist-info → openfeature_provider_flagd-0.2.7.dist-info}/licenses/LICENSE +1 -1
  30. openfeature/.gitignore +0 -2
  31. openfeature_provider_flagd-0.2.5.dist-info/METADATA +0 -370
  32. 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[dict, list],
205
+ default_value: typing.Union[
206
+ typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]
207
+ ],
203
208
  evaluation_context: typing.Optional[EvaluationContext] = None,
204
- ) -> FlagResolutionDetails[typing.Union[dict, list]]:
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
- channel_args = {
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
- channel_args["credentials"] = grpc.ssl_channel_credentials(f.read())
126
+ credentials = grpc.ssl_channel_credentials(f.read())
131
127
 
132
- channel = grpc.secure_channel(target, **channel_args)
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.time()
165
- while not self.connected and time.time() < timeout:
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
- {"timeout": self.streamline_deadline_seconds}
225
- if self.streamline_deadline_seconds > 0
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[dict, list],
298
+ default_value: typing.Union[
299
+ typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]
300
+ ],
304
301
  evaluation_context: typing.Optional[EvaluationContext] = None,
305
- ) -> FlagResolutionDetails[typing.Union[dict, list]]:
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: T,
360
+ default_value: FlagValueType,
313
361
  evaluation_context: typing.Optional[EvaluationContext],
314
- ) -> FlagResolutionDetails[T]:
362
+ ) -> FlagResolutionDetails[FlagValueType]:
315
363
  if self.cache is not None and flag_key in self.cache:
316
- cached_flag: FlagResolutionDetails[T] = self.cache[flag_key]
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 = {"timeout": self.deadline, "wait_for_ready": True}
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(evaluation_context.attributes)
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[dict, list],
108
+ default_value: typing.Union[
109
+ typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]
110
+ ],
109
111
  evaluation_context: typing.Optional[EvaluationContext] = None,
110
- ) -> FlagResolutionDetails[typing.Union[dict, list]]:
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
- variant, value = flag.default
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
- variant = targeting(flag.key, flag.targeting, evaluation_context)
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
- if variant is None:
139
- variant, value = flag.default
140
- return FlagResolutionDetails(
141
- value, variant=variant, flag_metadata=metadata, reason=Reason.DEFAULT
142
- )
143
- if not isinstance(variant, (str, bool)):
144
- raise ParseError(
145
- "Parsed JSONLogic targeting did not return a string or bool"
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 ParseError(f"Resolved variant {variant} not in variants config.")
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 google.protobuf.struct_pb2 import Struct
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
- channel_args = {
109
- "options": options,
110
- "credentials": config.channel_credentials,
111
- }
112
- channel = grpc.secure_channel(target, **channel_args)
107
+ channel = grpc.secure_channel(
108
+ target,
109
+ credentials=config.channel_credentials,
110
+ options=options,
111
+ )
113
112
 
114
113
  elif config.tls:
115
- channel_args = {
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
- channel_args["credentials"] = grpc.ssl_channel_credentials(f.read())
117
+ credentials = grpc.ssl_channel_credentials(f.read())
122
118
 
123
- channel = grpc.secure_channel(target, **channel_args)
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.time()
146
- while not self.connected and time.time() < timeout:
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
- {"timeout": self.streamline_deadline_seconds}
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: sync_pb2.GetMetadataResponse
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["metadata"],
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 isinstance(self.state, str):
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 not self.default_variant or not isinstance(
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
- json_logic_context = evaluation_context.attributes if evaluation_context else {}
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[dict, list],
45
+ default_value: typing.Union[
46
+ typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]
47
+ ],
46
48
  evaluation_context: typing.Optional[EvaluationContext] = None,
47
- ) -> FlagResolutionDetails[typing.Union[dict, list]]: ...
49
+ ) -> FlagResolutionDetails[
50
+ typing.Union[typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]]
51
+ ]: ...
@@ -0,0 +1,7 @@
1
+ import typing
2
+
3
+
4
+ class GrpcMultiCallableArgs(typing.TypedDict, total=False):
5
+ timeout: typing.Optional[float]
6
+ wait_for_ready: typing.Optional[bool]
7
+ metadata: typing.Optional[tuple[tuple[str, str]]]