openfeature-provider-flagd 0.1.4__py3-none-any.whl → 0.1.5__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.
@@ -1,5 +1,6 @@
1
1
  import os
2
2
  import typing
3
+ from enum import Enum
3
4
 
4
5
  T = typing.TypeVar("T")
5
6
 
@@ -17,13 +18,21 @@ def env_or_default(
17
18
  return val if cast is None else cast(val)
18
19
 
19
20
 
21
+ class ResolverType(Enum):
22
+ GRPC = "grpc"
23
+ IN_PROCESS = "in-process"
24
+
25
+
20
26
  class Config:
21
- def __init__(
27
+ def __init__( # noqa: PLR0913
22
28
  self,
23
29
  host: typing.Optional[str] = None,
24
30
  port: typing.Optional[int] = None,
25
31
  tls: typing.Optional[bool] = None,
26
32
  timeout: typing.Optional[int] = None,
33
+ resolver_type: typing.Optional[ResolverType] = None,
34
+ offline_flag_source_path: typing.Optional[str] = None,
35
+ offline_poll_interval_seconds: typing.Optional[float] = None,
27
36
  ):
28
37
  self.host = env_or_default("FLAGD_HOST", "localhost") if host is None else host
29
38
  self.port = (
@@ -33,3 +42,18 @@ class Config:
33
42
  env_or_default("FLAGD_TLS", False, cast=str_to_bool) if tls is None else tls
34
43
  )
35
44
  self.timeout = 5 if timeout is None else timeout
45
+ self.resolver_type = (
46
+ ResolverType(env_or_default("FLAGD_RESOLVER_TYPE", "grpc"))
47
+ if resolver_type is None
48
+ else resolver_type
49
+ )
50
+ self.offline_flag_source_path = (
51
+ env_or_default("FLAGD_OFFLINE_FLAG_SOURCE_PATH", None)
52
+ if offline_flag_source_path is None
53
+ else offline_flag_source_path
54
+ )
55
+ self.offline_poll_interval_seconds = (
56
+ float(env_or_default("FLAGD_OFFLINE_POLL_INTERVAL_SECONDS", 1.0))
57
+ if offline_poll_interval_seconds is None
58
+ else offline_poll_interval_seconds
59
+ )
@@ -23,24 +23,13 @@
23
23
 
24
24
  import typing
25
25
 
26
- import grpc
27
- from google.protobuf.struct_pb2 import Struct
28
-
29
26
  from openfeature.evaluation_context import EvaluationContext
30
- from openfeature.exception import (
31
- FlagNotFoundError,
32
- GeneralError,
33
- InvalidContextError,
34
- ParseError,
35
- TypeMismatchError,
36
- )
37
27
  from openfeature.flag_evaluation import FlagResolutionDetails
38
28
  from openfeature.provider.metadata import Metadata
39
29
  from openfeature.provider.provider import AbstractProvider
40
30
 
41
- from .config import Config
42
- from .flag_type import FlagType
43
- from .proto.schema.v1 import schema_pb2, schema_pb2_grpc
31
+ from .config import Config, ResolverType
32
+ from .resolvers import AbstractResolver, GrpcResolver, InProcessResolver
44
33
 
45
34
  T = typing.TypeVar("T")
46
35
 
@@ -48,12 +37,15 @@ T = typing.TypeVar("T")
48
37
  class FlagdProvider(AbstractProvider):
49
38
  """Flagd OpenFeature Provider"""
50
39
 
51
- def __init__(
40
+ def __init__( # noqa: PLR0913
52
41
  self,
53
42
  host: typing.Optional[str] = None,
54
43
  port: typing.Optional[int] = None,
55
44
  tls: typing.Optional[bool] = None,
56
45
  timeout: typing.Optional[int] = None,
46
+ resolver_type: typing.Optional[ResolverType] = None,
47
+ offline_flag_source_path: typing.Optional[str] = None,
48
+ offline_poll_interval_seconds: typing.Optional[float] = None,
57
49
  ):
58
50
  """
59
51
  Create an instance of the FlagdProvider
@@ -68,14 +60,26 @@ class FlagdProvider(AbstractProvider):
68
60
  port=port,
69
61
  tls=tls,
70
62
  timeout=timeout,
63
+ resolver_type=resolver_type,
64
+ offline_flag_source_path=offline_flag_source_path,
65
+ offline_poll_interval_seconds=offline_poll_interval_seconds,
71
66
  )
72
67
 
73
- channel_factory = grpc.secure_channel if tls else grpc.insecure_channel
74
- self.channel = channel_factory(f"{self.config.host}:{self.config.port}")
75
- self.stub = schema_pb2_grpc.ServiceStub(self.channel)
68
+ self.resolver = self.setup_resolver()
69
+
70
+ def setup_resolver(self) -> AbstractResolver:
71
+ if self.config.resolver_type == ResolverType.GRPC:
72
+ return GrpcResolver(self.config)
73
+ elif self.config.resolver_type == ResolverType.IN_PROCESS:
74
+ return InProcessResolver(self.config, self)
75
+ else:
76
+ raise ValueError(
77
+ f"`resolver_type` parameter invalid: {self.config.resolver_type}"
78
+ )
76
79
 
77
80
  def shutdown(self) -> None:
78
- self.channel.close()
81
+ if self.resolver:
82
+ self.resolver.shutdown()
79
83
 
80
84
  def get_metadata(self) -> Metadata:
81
85
  """Returns provider metadata"""
@@ -87,7 +91,9 @@ class FlagdProvider(AbstractProvider):
87
91
  default_value: bool,
88
92
  evaluation_context: typing.Optional[EvaluationContext] = None,
89
93
  ) -> FlagResolutionDetails[bool]:
90
- return self._resolve(key, FlagType.BOOLEAN, default_value, evaluation_context)
94
+ return self.resolver.resolve_boolean_details(
95
+ key, default_value, evaluation_context
96
+ )
91
97
 
92
98
  def resolve_string_details(
93
99
  self,
@@ -95,7 +101,9 @@ class FlagdProvider(AbstractProvider):
95
101
  default_value: str,
96
102
  evaluation_context: typing.Optional[EvaluationContext] = None,
97
103
  ) -> FlagResolutionDetails[str]:
98
- return self._resolve(key, FlagType.STRING, default_value, evaluation_context)
104
+ return self.resolver.resolve_string_details(
105
+ key, default_value, evaluation_context
106
+ )
99
107
 
100
108
  def resolve_float_details(
101
109
  self,
@@ -103,7 +111,9 @@ class FlagdProvider(AbstractProvider):
103
111
  default_value: float,
104
112
  evaluation_context: typing.Optional[EvaluationContext] = None,
105
113
  ) -> FlagResolutionDetails[float]:
106
- return self._resolve(key, FlagType.FLOAT, default_value, evaluation_context)
114
+ return self.resolver.resolve_float_details(
115
+ key, default_value, evaluation_context
116
+ )
107
117
 
108
118
  def resolve_integer_details(
109
119
  self,
@@ -111,7 +121,9 @@ class FlagdProvider(AbstractProvider):
111
121
  default_value: int,
112
122
  evaluation_context: typing.Optional[EvaluationContext] = None,
113
123
  ) -> FlagResolutionDetails[int]:
114
- return self._resolve(key, FlagType.INTEGER, default_value, evaluation_context)
124
+ return self.resolver.resolve_integer_details(
125
+ key, default_value, evaluation_context
126
+ )
115
127
 
116
128
  def resolve_object_details(
117
129
  self,
@@ -119,76 +131,6 @@ class FlagdProvider(AbstractProvider):
119
131
  default_value: typing.Union[dict, list],
120
132
  evaluation_context: typing.Optional[EvaluationContext] = None,
121
133
  ) -> FlagResolutionDetails[typing.Union[dict, list]]:
122
- return self._resolve(key, FlagType.OBJECT, default_value, evaluation_context)
123
-
124
- def _resolve(
125
- self,
126
- flag_key: str,
127
- flag_type: FlagType,
128
- default_value: T,
129
- evaluation_context: typing.Optional[EvaluationContext],
130
- ) -> FlagResolutionDetails[T]:
131
- context = self._convert_context(evaluation_context)
132
- call_args = {"timeout": self.config.timeout}
133
- try:
134
- if flag_type == FlagType.BOOLEAN:
135
- request = schema_pb2.ResolveBooleanRequest( # type:ignore[attr-defined]
136
- flag_key=flag_key, context=context
137
- )
138
- response = self.stub.ResolveBoolean(request, **call_args)
139
- elif flag_type == FlagType.STRING:
140
- request = schema_pb2.ResolveStringRequest( # type:ignore[attr-defined]
141
- flag_key=flag_key, context=context
142
- )
143
- response = self.stub.ResolveString(request, **call_args)
144
- elif flag_type == FlagType.OBJECT:
145
- request = schema_pb2.ResolveObjectRequest( # type:ignore[attr-defined]
146
- flag_key=flag_key, context=context
147
- )
148
- response = self.stub.ResolveObject(request, **call_args)
149
- elif flag_type == FlagType.FLOAT:
150
- request = schema_pb2.ResolveFloatRequest( # type:ignore[attr-defined]
151
- flag_key=flag_key, context=context
152
- )
153
- response = self.stub.ResolveFloat(request, **call_args)
154
- elif flag_type == FlagType.INTEGER:
155
- request = schema_pb2.ResolveIntRequest( # type:ignore[attr-defined]
156
- flag_key=flag_key, context=context
157
- )
158
- response = self.stub.ResolveInt(request, **call_args)
159
- else:
160
- raise ValueError(f"Unknown flag type: {flag_type}")
161
-
162
- except grpc.RpcError as e:
163
- code = e.code()
164
- message = f"received grpc status code {code}"
165
-
166
- if code == grpc.StatusCode.NOT_FOUND:
167
- raise FlagNotFoundError(message) from e
168
- elif code == grpc.StatusCode.INVALID_ARGUMENT:
169
- raise TypeMismatchError(message) from e
170
- elif code == grpc.StatusCode.DATA_LOSS:
171
- raise ParseError(message) from e
172
- raise GeneralError(message) from e
173
-
174
- # Got a valid flag and valid type. Return it.
175
- return FlagResolutionDetails(
176
- value=response.value,
177
- reason=response.reason,
178
- variant=response.variant,
134
+ return self.resolver.resolve_object_details(
135
+ key, default_value, evaluation_context
179
136
  )
180
-
181
- def _convert_context(
182
- self, evaluation_context: typing.Optional[EvaluationContext]
183
- ) -> Struct:
184
- s = Struct()
185
- if evaluation_context:
186
- try:
187
- s["targetingKey"] = evaluation_context.targeting_key
188
- s.update(evaluation_context.attributes)
189
- except ValueError as exc:
190
- message = (
191
- "could not serialize evaluation context to google.protobuf.Struct"
192
- )
193
- raise InvalidContextError(message) from exc
194
- return s
@@ -0,0 +1,51 @@
1
+ import typing
2
+
3
+ from typing_extensions import Protocol
4
+
5
+ from openfeature.evaluation_context import EvaluationContext
6
+ from openfeature.flag_evaluation import FlagResolutionDetails
7
+
8
+ from .grpc import GrpcResolver
9
+ from .in_process import InProcessResolver
10
+
11
+
12
+ class AbstractResolver(Protocol):
13
+ def shutdown(self) -> None: ...
14
+
15
+ def resolve_boolean_details(
16
+ self,
17
+ key: str,
18
+ default_value: bool,
19
+ evaluation_context: typing.Optional[EvaluationContext] = None,
20
+ ) -> FlagResolutionDetails[bool]: ...
21
+
22
+ def resolve_string_details(
23
+ self,
24
+ key: str,
25
+ default_value: str,
26
+ evaluation_context: typing.Optional[EvaluationContext] = None,
27
+ ) -> FlagResolutionDetails[str]: ...
28
+
29
+ def resolve_float_details(
30
+ self,
31
+ key: str,
32
+ default_value: float,
33
+ evaluation_context: typing.Optional[EvaluationContext] = None,
34
+ ) -> FlagResolutionDetails[float]: ...
35
+
36
+ def resolve_integer_details(
37
+ self,
38
+ key: str,
39
+ default_value: int,
40
+ evaluation_context: typing.Optional[EvaluationContext] = None,
41
+ ) -> FlagResolutionDetails[int]: ...
42
+
43
+ def resolve_object_details(
44
+ self,
45
+ key: str,
46
+ default_value: typing.Union[dict, list],
47
+ evaluation_context: typing.Optional[EvaluationContext] = None,
48
+ ) -> FlagResolutionDetails[typing.Union[dict, list]]: ...
49
+
50
+
51
+ __all__ = ["AbstractResolver", "GrpcResolver", "InProcessResolver"]
@@ -0,0 +1,145 @@
1
+ import typing
2
+
3
+ import grpc
4
+ from google.protobuf.struct_pb2 import Struct
5
+
6
+ from openfeature.evaluation_context import EvaluationContext
7
+ from openfeature.exception import (
8
+ FlagNotFoundError,
9
+ GeneralError,
10
+ InvalidContextError,
11
+ ParseError,
12
+ TypeMismatchError,
13
+ )
14
+ from openfeature.flag_evaluation import FlagResolutionDetails
15
+
16
+ from ..config import Config
17
+ from ..flag_type import FlagType
18
+ from ..proto.schema.v1 import schema_pb2, schema_pb2_grpc
19
+
20
+ T = typing.TypeVar("T")
21
+
22
+
23
+ class GrpcResolver:
24
+ def __init__(self, config: Config):
25
+ self.config = config
26
+ channel_factory = (
27
+ grpc.secure_channel if self.config.tls else grpc.insecure_channel
28
+ )
29
+ self.channel = channel_factory(f"{self.config.host}:{self.config.port}")
30
+ self.stub = schema_pb2_grpc.ServiceStub(self.channel)
31
+
32
+ def shutdown(self) -> None:
33
+ self.channel.close()
34
+
35
+ def resolve_boolean_details(
36
+ self,
37
+ key: str,
38
+ default_value: bool,
39
+ evaluation_context: typing.Optional[EvaluationContext] = None,
40
+ ) -> FlagResolutionDetails[bool]:
41
+ return self._resolve(key, FlagType.BOOLEAN, default_value, evaluation_context)
42
+
43
+ def resolve_string_details(
44
+ self,
45
+ key: str,
46
+ default_value: str,
47
+ evaluation_context: typing.Optional[EvaluationContext] = None,
48
+ ) -> FlagResolutionDetails[str]:
49
+ return self._resolve(key, FlagType.STRING, default_value, evaluation_context)
50
+
51
+ def resolve_float_details(
52
+ self,
53
+ key: str,
54
+ default_value: float,
55
+ evaluation_context: typing.Optional[EvaluationContext] = None,
56
+ ) -> FlagResolutionDetails[float]:
57
+ return self._resolve(key, FlagType.FLOAT, default_value, evaluation_context)
58
+
59
+ def resolve_integer_details(
60
+ self,
61
+ key: str,
62
+ default_value: int,
63
+ evaluation_context: typing.Optional[EvaluationContext] = None,
64
+ ) -> FlagResolutionDetails[int]:
65
+ return self._resolve(key, FlagType.INTEGER, default_value, evaluation_context)
66
+
67
+ def resolve_object_details(
68
+ self,
69
+ key: str,
70
+ default_value: typing.Union[dict, list],
71
+ evaluation_context: typing.Optional[EvaluationContext] = None,
72
+ ) -> FlagResolutionDetails[typing.Union[dict, list]]:
73
+ return self._resolve(key, FlagType.OBJECT, default_value, evaluation_context)
74
+
75
+ def _resolve(
76
+ self,
77
+ flag_key: str,
78
+ flag_type: FlagType,
79
+ default_value: T,
80
+ evaluation_context: typing.Optional[EvaluationContext],
81
+ ) -> FlagResolutionDetails[T]:
82
+ context = self._convert_context(evaluation_context)
83
+ call_args = {"timeout": self.config.timeout}
84
+ try:
85
+ if flag_type == FlagType.BOOLEAN:
86
+ request = schema_pb2.ResolveBooleanRequest( # type:ignore[attr-defined]
87
+ flag_key=flag_key, context=context
88
+ )
89
+ response = self.stub.ResolveBoolean(request, **call_args)
90
+ elif flag_type == FlagType.STRING:
91
+ request = schema_pb2.ResolveStringRequest( # type:ignore[attr-defined]
92
+ flag_key=flag_key, context=context
93
+ )
94
+ response = self.stub.ResolveString(request, **call_args)
95
+ elif flag_type == FlagType.OBJECT:
96
+ request = schema_pb2.ResolveObjectRequest( # type:ignore[attr-defined]
97
+ flag_key=flag_key, context=context
98
+ )
99
+ response = self.stub.ResolveObject(request, **call_args)
100
+ elif flag_type == FlagType.FLOAT:
101
+ request = schema_pb2.ResolveFloatRequest( # type:ignore[attr-defined]
102
+ flag_key=flag_key, context=context
103
+ )
104
+ response = self.stub.ResolveFloat(request, **call_args)
105
+ elif flag_type == FlagType.INTEGER:
106
+ request = schema_pb2.ResolveIntRequest( # type:ignore[attr-defined]
107
+ flag_key=flag_key, context=context
108
+ )
109
+ response = self.stub.ResolveInt(request, **call_args)
110
+ else:
111
+ raise ValueError(f"Unknown flag type: {flag_type}")
112
+
113
+ except grpc.RpcError as e:
114
+ code = e.code()
115
+ message = f"received grpc status code {code}"
116
+
117
+ if code == grpc.StatusCode.NOT_FOUND:
118
+ raise FlagNotFoundError(message) from e
119
+ elif code == grpc.StatusCode.INVALID_ARGUMENT:
120
+ raise TypeMismatchError(message) from e
121
+ elif code == grpc.StatusCode.DATA_LOSS:
122
+ raise ParseError(message) from e
123
+ raise GeneralError(message) from e
124
+
125
+ # Got a valid flag and valid type. Return it.
126
+ return FlagResolutionDetails(
127
+ value=response.value,
128
+ reason=response.reason,
129
+ variant=response.variant,
130
+ )
131
+
132
+ def _convert_context(
133
+ self, evaluation_context: typing.Optional[EvaluationContext]
134
+ ) -> Struct:
135
+ s = Struct()
136
+ if evaluation_context:
137
+ try:
138
+ s["targetingKey"] = evaluation_context.targeting_key
139
+ s.update(evaluation_context.attributes)
140
+ except ValueError as exc:
141
+ message = (
142
+ "could not serialize evaluation context to google.protobuf.Struct"
143
+ )
144
+ raise InvalidContextError(message) from exc
145
+ return s
@@ -0,0 +1,122 @@
1
+ import time
2
+ import typing
3
+
4
+ from json_logic import builtins, jsonLogic # type: ignore[import-untyped]
5
+
6
+ from openfeature.evaluation_context import EvaluationContext
7
+ from openfeature.exception import FlagNotFoundError, ParseError
8
+ from openfeature.flag_evaluation import FlagResolutionDetails, Reason
9
+ from openfeature.provider.provider import AbstractProvider
10
+
11
+ from ..config import Config
12
+ from .process.custom_ops import ends_with, fractional, sem_ver, starts_with
13
+ from .process.file_watcher import FileWatcherFlagStore
14
+
15
+ T = typing.TypeVar("T")
16
+
17
+
18
+ class InProcessResolver:
19
+ OPERATORS: typing.ClassVar[dict] = {
20
+ **builtins.BUILTINS,
21
+ "fractional": fractional,
22
+ "starts_with": starts_with,
23
+ "ends_with": ends_with,
24
+ "sem_ver": sem_ver,
25
+ }
26
+
27
+ def __init__(self, config: Config, provider: AbstractProvider):
28
+ self.config = config
29
+ self.provider = provider
30
+ if not self.config.offline_flag_source_path:
31
+ raise ValueError(
32
+ "offline_flag_source_path must be provided when using in-process resolver"
33
+ )
34
+ self.flag_store = FileWatcherFlagStore(
35
+ self.config.offline_flag_source_path,
36
+ self.provider,
37
+ self.config.offline_poll_interval_seconds,
38
+ )
39
+
40
+ def shutdown(self) -> None:
41
+ self.flag_store.shutdown()
42
+
43
+ def resolve_boolean_details(
44
+ self,
45
+ key: str,
46
+ default_value: bool,
47
+ evaluation_context: typing.Optional[EvaluationContext] = None,
48
+ ) -> FlagResolutionDetails[bool]:
49
+ return self._resolve(key, default_value, evaluation_context)
50
+
51
+ def resolve_string_details(
52
+ self,
53
+ key: str,
54
+ default_value: str,
55
+ evaluation_context: typing.Optional[EvaluationContext] = None,
56
+ ) -> FlagResolutionDetails[str]:
57
+ return self._resolve(key, default_value, evaluation_context)
58
+
59
+ def resolve_float_details(
60
+ self,
61
+ key: str,
62
+ default_value: float,
63
+ evaluation_context: typing.Optional[EvaluationContext] = None,
64
+ ) -> FlagResolutionDetails[float]:
65
+ return self._resolve(key, default_value, evaluation_context)
66
+
67
+ def resolve_integer_details(
68
+ self,
69
+ key: str,
70
+ default_value: int,
71
+ evaluation_context: typing.Optional[EvaluationContext] = None,
72
+ ) -> FlagResolutionDetails[int]:
73
+ return self._resolve(key, default_value, evaluation_context)
74
+
75
+ def resolve_object_details(
76
+ self,
77
+ key: str,
78
+ default_value: typing.Union[dict, list],
79
+ evaluation_context: typing.Optional[EvaluationContext] = None,
80
+ ) -> FlagResolutionDetails[typing.Union[dict, list]]:
81
+ return self._resolve(key, default_value, evaluation_context)
82
+
83
+ def _resolve(
84
+ self,
85
+ key: str,
86
+ default_value: T,
87
+ evaluation_context: typing.Optional[EvaluationContext] = None,
88
+ ) -> FlagResolutionDetails[T]:
89
+ flag = self.flag_store.get_flag(key)
90
+ if not flag:
91
+ raise FlagNotFoundError(f"Flag with key {key} not present in flag store.")
92
+
93
+ if flag.state == "DISABLED":
94
+ return FlagResolutionDetails(default_value, reason=Reason.DISABLED)
95
+
96
+ if not flag.targeting:
97
+ variant, value = flag.default
98
+ return FlagResolutionDetails(value, variant=variant, reason=Reason.STATIC)
99
+
100
+ json_logic_context = evaluation_context.attributes if evaluation_context else {}
101
+ json_logic_context["$flagd"] = {"flagKey": key, "timestamp": int(time.time())}
102
+ json_logic_context["targetingKey"] = (
103
+ evaluation_context.targeting_key if evaluation_context else None
104
+ )
105
+ variant = jsonLogic(flag.targeting, json_logic_context, self.OPERATORS)
106
+ if variant is None:
107
+ variant, value = flag.default
108
+ return FlagResolutionDetails(value, variant=variant, reason=Reason.DEFAULT)
109
+ if not isinstance(variant, (str, bool)):
110
+ raise ParseError(
111
+ "Parsed JSONLogic targeting did not return a string or bool"
112
+ )
113
+
114
+ variant, value = flag.get_variant(variant)
115
+ if not value:
116
+ raise ParseError(f"Resolved variant {variant} not in variants config.")
117
+
118
+ return FlagResolutionDetails(
119
+ value,
120
+ variant=variant,
121
+ reason=Reason.TARGETING_MATCH,
122
+ )
@@ -0,0 +1,126 @@
1
+ import logging
2
+ import typing
3
+
4
+ import mmh3
5
+ import semver
6
+
7
+ JsonPrimitive = typing.Union[str, bool, float, int]
8
+ JsonLogicArg = typing.Union[JsonPrimitive, typing.Sequence[JsonPrimitive]]
9
+
10
+ logger = logging.getLogger("openfeature.contrib")
11
+
12
+
13
+ def fractional(data: dict, *args: JsonLogicArg) -> typing.Optional[str]:
14
+ if not args:
15
+ logger.error("No arguments provided to fractional operator.")
16
+ return None
17
+
18
+ bucket_by = None
19
+ if isinstance(args[0], str):
20
+ bucket_by = args[0]
21
+ args = args[1:]
22
+ else:
23
+ seed = data.get("$flagd", {}).get("flagKey", "")
24
+ targeting_key = data.get("targetingKey")
25
+ if not targeting_key:
26
+ logger.error("No targetingKey provided for fractional shorthand syntax.")
27
+ return None
28
+ bucket_by = seed + targeting_key
29
+
30
+ if not bucket_by:
31
+ logger.error("No hashKey value resolved")
32
+ return None
33
+
34
+ hash_ratio = abs(mmh3.hash(bucket_by)) / (2**31 - 1)
35
+ bucket = int(hash_ratio * 100)
36
+
37
+ for arg in args:
38
+ if (
39
+ not isinstance(arg, (tuple, list))
40
+ or len(arg) != 2
41
+ or not isinstance(arg[0], str)
42
+ or not isinstance(arg[1], int)
43
+ ):
44
+ logger.error("Fractional variant weights must be (str, int) tuple")
45
+ return None
46
+ variant_weights: typing.Tuple[typing.Tuple[str, int]] = args # type: ignore[assignment]
47
+
48
+ range_end = 0
49
+ for variant, weight in variant_weights:
50
+ range_end += weight
51
+ if bucket < range_end:
52
+ return variant
53
+
54
+ return None
55
+
56
+
57
+ def starts_with(data: dict, *args: JsonLogicArg) -> typing.Optional[bool]:
58
+ def f(s1: str, s2: str) -> bool:
59
+ return s1.startswith(s2)
60
+
61
+ return string_comp(f, data, *args)
62
+
63
+
64
+ def ends_with(data: dict, *args: JsonLogicArg) -> typing.Optional[bool]:
65
+ def f(s1: str, s2: str) -> bool:
66
+ return s1.endswith(s2)
67
+
68
+ return string_comp(f, data, *args)
69
+
70
+
71
+ def string_comp(
72
+ comparator: typing.Callable[[str, str], bool], data: dict, *args: JsonLogicArg
73
+ ) -> typing.Optional[bool]:
74
+ if not args:
75
+ logger.error("No arguments provided to string_comp operator.")
76
+ return None
77
+ if len(args) != 2:
78
+ logger.error("Exactly 2 args expected for string_comp operator.")
79
+ return None
80
+ arg1, arg2 = args
81
+ if not isinstance(arg1, str):
82
+ logger.debug(f"incorrect argument for first argument, expected string: {arg1}")
83
+ return False
84
+ if not isinstance(arg2, str):
85
+ logger.debug(f"incorrect argument for second argument, expected string: {arg2}")
86
+ return False
87
+
88
+ return comparator(arg1, arg2)
89
+
90
+
91
+ def sem_ver(data: dict, *args: JsonLogicArg) -> typing.Optional[bool]: # noqa: C901
92
+ if not args:
93
+ logger.error("No arguments provided to sem_ver operator.")
94
+ return None
95
+ if len(args) != 3:
96
+ logger.error("Exactly 3 args expected for sem_ver operator.")
97
+ return None
98
+
99
+ arg1, op, arg2 = args
100
+
101
+ try:
102
+ v1 = semver.Version.parse(str(arg1))
103
+ v2 = semver.Version.parse(str(arg2))
104
+ except ValueError as e:
105
+ logger.exception(e)
106
+ return None
107
+
108
+ if op == "=":
109
+ return v1 == v2
110
+ elif op == "!=":
111
+ return v1 != v2
112
+ elif op == "<":
113
+ return v1 < v2
114
+ elif op == "<=":
115
+ return v1 <= v2
116
+ elif op == ">":
117
+ return v1 > v2
118
+ elif op == ">=":
119
+ return v1 >= v2
120
+ elif op == "^":
121
+ return v1.major == v2.major
122
+ elif op == "~":
123
+ return v1.major == v2.major and v1.minor == v2.minor
124
+ else:
125
+ logger.error(f"Op not supported by sem_ver: {op}")
126
+ return None
@@ -0,0 +1,89 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import re
5
+ import threading
6
+ import time
7
+ import typing
8
+
9
+ import yaml
10
+
11
+ from openfeature.event import ProviderEventDetails
12
+ from openfeature.exception import ParseError
13
+ from openfeature.provider.provider import AbstractProvider
14
+
15
+ from .flags import Flag
16
+
17
+ logger = logging.getLogger("openfeature.contrib")
18
+
19
+
20
+ class FileWatcherFlagStore:
21
+ def __init__(
22
+ self,
23
+ file_path: str,
24
+ provider: AbstractProvider,
25
+ poll_interval_seconds: float = 1.0,
26
+ ):
27
+ self.file_path = file_path
28
+ self.provider = provider
29
+ self.poll_interval_seconds = poll_interval_seconds
30
+
31
+ self.last_modified = 0.0
32
+ self.flag_data: typing.Mapping[str, Flag] = {}
33
+ self.load_data()
34
+ self.thread = threading.Thread(target=self.refresh_file, daemon=True)
35
+ self.thread.start()
36
+
37
+ def shutdown(self) -> None:
38
+ pass
39
+
40
+ def get_flag(self, key: str) -> typing.Optional[Flag]:
41
+ return self.flag_data.get(key)
42
+
43
+ def refresh_file(self) -> None:
44
+ while True:
45
+ time.sleep(self.poll_interval_seconds)
46
+ logger.debug("checking for new flag store contents from file")
47
+ last_modified = os.path.getmtime(self.file_path)
48
+ if last_modified > self.last_modified:
49
+ self.load_data(last_modified)
50
+
51
+ def load_data(self, modified_time: typing.Optional[float] = None) -> None:
52
+ try:
53
+ with open(self.file_path) as file:
54
+ if self.file_path.endswith(".yaml"):
55
+ data = yaml.safe_load(file)
56
+ else:
57
+ data = json.load(file)
58
+
59
+ self.flag_data = self.parse_flags(data)
60
+ logger.debug(f"{self.flag_data=}")
61
+ self.provider.emit_provider_configuration_changed(
62
+ ProviderEventDetails(flags_changed=list(self.flag_data.keys()))
63
+ )
64
+ self.last_modified = modified_time or os.path.getmtime(self.file_path)
65
+ except FileNotFoundError:
66
+ logger.exception("Provided file path not valid")
67
+ except json.JSONDecodeError:
68
+ logger.exception("Could not parse JSON flag data from file")
69
+ except yaml.error.YAMLError:
70
+ logger.exception("Could not parse YAML flag data from file")
71
+ except ParseError:
72
+ logger.exception("Could not parse flag data using flagd syntax")
73
+ except Exception:
74
+ logger.exception("Could not read flags from file")
75
+
76
+ def parse_flags(self, flags_data: dict) -> dict:
77
+ flags = flags_data.get("flags", {})
78
+ evaluators: typing.Optional[dict] = flags_data.get("$evaluators")
79
+ if evaluators:
80
+ transposed = json.dumps(flags)
81
+ for name, rule in evaluators.items():
82
+ transposed = re.sub(
83
+ rf"{{\s*\"\$ref\":\s*\"{name}\"\s*}}", json.dumps(rule), transposed
84
+ )
85
+ flags = json.loads(transposed)
86
+
87
+ if not isinstance(flags, dict):
88
+ raise ParseError("`flags` key of configuration must be a dictionary")
89
+ return {key: Flag.from_dict(key, data) for key, data in flags.items()}
@@ -0,0 +1,51 @@
1
+ import typing
2
+ from dataclasses import dataclass
3
+
4
+ from openfeature.exception import ParseError
5
+
6
+
7
+ @dataclass
8
+ class Flag:
9
+ key: str
10
+ state: str
11
+ variants: typing.Mapping[str, typing.Any]
12
+ default_variant: typing.Union[bool, str]
13
+ targeting: typing.Optional[dict] = None
14
+
15
+ def __post_init__(self) -> None:
16
+ if not self.state or not isinstance(self.state, str):
17
+ raise ParseError("Incorrect 'state' value provided in flag config")
18
+
19
+ if not self.variants or not isinstance(self.variants, dict):
20
+ raise ParseError("Incorrect 'variants' value provided in flag config")
21
+
22
+ if not self.default_variant or not isinstance(
23
+ self.default_variant, (str, bool)
24
+ ):
25
+ raise ParseError("Incorrect 'defaultVariant' value provided in flag config")
26
+
27
+ if self.targeting and not isinstance(self.targeting, dict):
28
+ raise ParseError("Incorrect 'targeting' value provided in flag config")
29
+
30
+ if self.default_variant not in self.variants:
31
+ raise ParseError("Default variant does not match set of variants")
32
+
33
+ @classmethod
34
+ def from_dict(cls, key: str, data: dict) -> "Flag":
35
+ data["default_variant"] = data["defaultVariant"]
36
+ del data["defaultVariant"]
37
+ flag = cls(key=key, **data)
38
+
39
+ return flag
40
+
41
+ @property
42
+ def default(self) -> typing.Tuple[str, typing.Any]:
43
+ return self.get_variant(self.default_variant)
44
+
45
+ def get_variant(
46
+ self, variant_key: typing.Union[str, bool]
47
+ ) -> typing.Tuple[str, typing.Any]:
48
+ if isinstance(variant_key, bool):
49
+ variant_key = str(variant_key).lower()
50
+
51
+ return variant_key, self.variants.get(variant_key)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: openfeature-provider-flagd
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: OpenFeature provider for the flagd flag evaluation engine
5
5
  Project-URL: Homepage, https://github.com/open-feature/python-sdk-contrib
6
6
  Author-email: OpenFeature <openfeature-core@groups.io>
@@ -211,8 +211,12 @@ Classifier: Programming Language :: Python
211
211
  Classifier: Programming Language :: Python :: 3
212
212
  Requires-Python: >=3.8
213
213
  Requires-Dist: grpcio>=1.60.0
214
+ Requires-Dist: mmh3>=4.1.0
214
215
  Requires-Dist: openfeature-sdk>=0.4.0
216
+ Requires-Dist: panzi-json-logic>=1.0.1
215
217
  Requires-Dist: protobuf>=4.25.2
218
+ Requires-Dist: pyyaml>=6.0.1
219
+ Requires-Dist: semver<4,>=3
216
220
  Description-Content-Type: text/markdown
217
221
 
218
222
  # flagd Provider for OpenFeature
@@ -236,6 +240,19 @@ from openfeature.contrib.provider.flagd import FlagdProvider
236
240
  api.set_provider(FlagdProvider())
237
241
  ```
238
242
 
243
+ To use in-process evaluation in offline mode with a file as source:
244
+
245
+ ```python
246
+ from openfeature import api
247
+ from openfeature.contrib.provider.flagd import FlagdProvider
248
+ from openfeature.contrib.provider.flagd.config import ResolverType
249
+
250
+ api.set_provider(FlagdProvider(
251
+ resolver_type=ResolverType.IN_PROCESS,
252
+ offline_flag_source_path="my-flag.json",
253
+ ))
254
+ ```
255
+
239
256
  ### Configuration options
240
257
 
241
258
  The default options can be defined in the FlagdProvider constructor.
@@ -1,7 +1,7 @@
1
1
  openfeature/contrib/provider/flagd/__init__.py,sha256=WlrcPaCH31dEG1IvrvpeuhAaQ8Ni8LEzDpNM_x-qKOA,65
2
- openfeature/contrib/provider/flagd/config.py,sha256=8Oguwoe4V6FffdNNBfhWev2G8UjKsNYCuc_j3SGEb0Y,987
2
+ openfeature/contrib/provider/flagd/config.py,sha256=DF_pAm1jYWIUTS7564YHI1mLBb1v3z7MstRgMwUHsmA,1921
3
3
  openfeature/contrib/provider/flagd/flag_type.py,sha256=rZYfmqQEmtqVVTb8e-d8Wt8ZCnHtf7xPSmYxyU8w0R0,158
4
- openfeature/contrib/provider/flagd/provider.py,sha256=0hXxolWFUVue-6OgrC1kXa64B-bbmvc6ZtqAyexDHIc,7093
4
+ openfeature/contrib/provider/flagd/provider.py,sha256=i5_hJmy-EdbltQaPfcbCk6j9k1YkUnKxgubzKIQK3dQ,4614
5
5
  openfeature/contrib/provider/flagd/proto/flagd/evaluation/v1/evaluation_pb2.py,sha256=W0Vgg8z7nEg_HTlAJkO6pu5nBcR8Ls3yoIykboavLRI,7704
6
6
  openfeature/contrib/provider/flagd/proto/flagd/evaluation/v1/evaluation_pb2_grpc.py,sha256=GTKQXbUfRrquUG5o11N7ymhFnOuzh9_hcToZ9-cjOtA,13339
7
7
  openfeature/contrib/provider/flagd/proto/flagd/sync/v1/sync_pb2.py,sha256=zUu_-V6DhHH5ZekWVsxKjGkUwkJrRrhqyXj8oX-Jo_g,3205
@@ -10,7 +10,13 @@ openfeature/contrib/provider/flagd/proto/schema/v1/schema_pb2.py,sha256=onIastnV
10
10
  openfeature/contrib/provider/flagd/proto/schema/v1/schema_pb2_grpc.py,sha256=nllthJYfwhDBZHH_eGfSHesNbO6rJEZ0vXKyTFFy4AA,12393
11
11
  openfeature/contrib/provider/flagd/proto/sync/v1/sync_service_pb2.py,sha256=uKBjB_lpfHN3r8wsjmqnQEBLtN9gw8zPmvWfWKsOYAE,2954
12
12
  openfeature/contrib/provider/flagd/proto/sync/v1/sync_service_pb2_grpc.py,sha256=v5MSyyIOpE68EYWjsbdAq677gnD4jdNeLhMAB7EveNQ,4424
13
- openfeature_provider_flagd-0.1.4.dist-info/METADATA,sha256=wtW6iavradbR6Y1d66M-SFcsMv8zjwQ5ylY4udCd8fs,14518
14
- openfeature_provider_flagd-0.1.4.dist-info/WHEEL,sha256=uNdcs2TADwSd5pVaP0Z_kcjcvvTUklh2S7bxZMF8Uj0,87
15
- openfeature_provider_flagd-0.1.4.dist-info/licenses/LICENSE,sha256=h8jwqxShIeVkc8vOo9ynxGYW16f4fVPxLhZKZs0H5U8,11350
16
- openfeature_provider_flagd-0.1.4.dist-info/RECORD,,
13
+ openfeature/contrib/provider/flagd/resolvers/__init__.py,sha256=kM5bt6cJCQgCsvJd1Tb2YD050X06wk8rK249JmNjoUc,1468
14
+ openfeature/contrib/provider/flagd/resolvers/grpc.py,sha256=9F-jX08Ynar5d2cgTV90lDQxgxIlxKg9KEIwueCU3u8,5460
15
+ openfeature/contrib/provider/flagd/resolvers/in_process.py,sha256=-RGKP9xC4Ic65-gBLvjMJJ-7EKDKsKnEu_0GdqGHaKk,4383
16
+ openfeature/contrib/provider/flagd/resolvers/process/custom_ops.py,sha256=8vscgX-SA_lnjb8Tg4S_qrVspqeVwtyXc8F846tcdc8,3644
17
+ openfeature/contrib/provider/flagd/resolvers/process/file_watcher.py,sha256=0GMA-gs6PchT4tW_daV1LwFmCw21mVa_WawusW72xWA,3191
18
+ openfeature/contrib/provider/flagd/resolvers/process/flags.py,sha256=aE-i-ghpjoGejOwP3K4DotcdHYw_o393oxrMaBmpgko,1718
19
+ openfeature_provider_flagd-0.1.5.dist-info/METADATA,sha256=rWFt0M5Qb54eiiLaEPhhHVCzwY5PlxLYZwEToiMaCTA,15005
20
+ openfeature_provider_flagd-0.1.5.dist-info/WHEEL,sha256=as-1oFTWSeWBgyzh0O_qF439xqBe6AbBgt4MfYe5zwY,87
21
+ openfeature_provider_flagd-0.1.5.dist-info/licenses/LICENSE,sha256=h8jwqxShIeVkc8vOo9ynxGYW16f4fVPxLhZKZs0H5U8,11350
22
+ openfeature_provider_flagd-0.1.5.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.22.4
2
+ Generator: hatchling 1.22.5
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any