openfeature-provider-flagd 0.1.3__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.
@@ -0,0 +1,59 @@
1
+ import os
2
+ import typing
3
+ from enum import Enum
4
+
5
+ T = typing.TypeVar("T")
6
+
7
+
8
+ def str_to_bool(val: str) -> bool:
9
+ return val.lower() == "true"
10
+
11
+
12
+ def env_or_default(
13
+ env_var: str, default: T, cast: typing.Optional[typing.Callable[[str], T]] = None
14
+ ) -> typing.Union[str, T]:
15
+ val = os.environ.get(env_var)
16
+ if val is None:
17
+ return default
18
+ return val if cast is None else cast(val)
19
+
20
+
21
+ class ResolverType(Enum):
22
+ GRPC = "grpc"
23
+ IN_PROCESS = "in-process"
24
+
25
+
26
+ class Config:
27
+ def __init__( # noqa: PLR0913
28
+ self,
29
+ host: typing.Optional[str] = None,
30
+ port: typing.Optional[int] = None,
31
+ tls: typing.Optional[bool] = None,
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,
36
+ ):
37
+ self.host = env_or_default("FLAGD_HOST", "localhost") if host is None else host
38
+ self.port = (
39
+ env_or_default("FLAGD_PORT", 8013, cast=int) if port is None else port
40
+ )
41
+ self.tls = (
42
+ env_or_default("FLAGD_TLS", False, cast=str_to_bool) if tls is None else tls
43
+ )
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
+ )
@@ -22,181 +22,115 @@
22
22
  """
23
23
 
24
24
  import typing
25
- from numbers import Number
26
-
27
- import grpc
28
- from google.protobuf.struct_pb2 import Struct
29
25
 
30
26
  from openfeature.evaluation_context import EvaluationContext
31
- from openfeature.exception import (
32
- FlagNotFoundError,
33
- GeneralError,
34
- InvalidContextError,
35
- ParseError,
36
- TypeMismatchError,
37
- )
38
- from openfeature.flag_evaluation import FlagEvaluationDetails
27
+ from openfeature.flag_evaluation import FlagResolutionDetails
28
+ from openfeature.provider.metadata import Metadata
39
29
  from openfeature.provider.provider import AbstractProvider
40
30
 
41
- from .defaults import Defaults
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
33
+
34
+ T = typing.TypeVar("T")
44
35
 
45
36
 
46
37
  class FlagdProvider(AbstractProvider):
47
38
  """Flagd OpenFeature Provider"""
48
39
 
49
- def __init__(
40
+ def __init__( # noqa: PLR0913
50
41
  self,
51
- name: str = "flagd",
52
- schema: str = Defaults.SCHEMA,
53
- host: str = Defaults.HOST,
54
- port: int = Defaults.PORT,
55
- timeout: int = Defaults.TIMEOUT,
42
+ host: typing.Optional[str] = None,
43
+ port: typing.Optional[int] = None,
44
+ tls: typing.Optional[bool] = None,
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,
56
49
  ):
57
50
  """
58
51
  Create an instance of the FlagdProvider
59
52
 
60
- :param name: the name of the provider to be stored in metadata
61
- :param schema: the schema for the transport protocol, e.g. 'http', 'https'
62
53
  :param host: the host to make requests to
63
54
  :param port: the port the flagd service is available on
55
+ :param tls: enable/disable secure TLS connectivity
64
56
  :param timeout: the maximum to wait before a request times out
65
57
  """
66
- self.provider_name = name
67
- self.schema = schema
68
- self.host = host
69
- self.port = port
70
- self.timeout = timeout
71
-
72
- channel_factory = (
73
- grpc.insecure_channel if schema == "http" else grpc.secure_channel
58
+ self.config = Config(
59
+ host=host,
60
+ port=port,
61
+ tls=tls,
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,
74
66
  )
75
- self.channel = channel_factory(f"{self.host}:{self.port}")
76
- self.stub = schema_pb2_grpc.ServiceStub(self.channel)
77
67
 
78
- def shutdown(self):
79
- self.channel.close()
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
+ )
80
79
 
81
- def get_metadata(self):
80
+ def shutdown(self) -> None:
81
+ if self.resolver:
82
+ self.resolver.shutdown()
83
+
84
+ def get_metadata(self) -> Metadata:
82
85
  """Returns provider metadata"""
83
- return {
84
- "name": self.get_name(),
85
- "schema": self.schema,
86
- "host": self.host,
87
- "port": self.port,
88
- "timeout": self.timeout,
89
- }
90
-
91
- def get_name(self) -> str:
92
- """Returns provider name"""
93
- return self.provider_name
86
+ return Metadata(name="FlagdProvider")
94
87
 
95
88
  def resolve_boolean_details(
96
89
  self,
97
90
  key: str,
98
91
  default_value: bool,
99
- evaluation_context: EvaluationContext = None,
100
- ):
101
- return self._resolve(key, FlagType.BOOLEAN, default_value, evaluation_context)
92
+ evaluation_context: typing.Optional[EvaluationContext] = None,
93
+ ) -> FlagResolutionDetails[bool]:
94
+ return self.resolver.resolve_boolean_details(
95
+ key, default_value, evaluation_context
96
+ )
102
97
 
103
98
  def resolve_string_details(
104
99
  self,
105
100
  key: str,
106
101
  default_value: str,
107
- evaluation_context: EvaluationContext = None,
108
- ):
109
- return self._resolve(key, FlagType.STRING, default_value, evaluation_context)
102
+ evaluation_context: typing.Optional[EvaluationContext] = None,
103
+ ) -> FlagResolutionDetails[str]:
104
+ return self.resolver.resolve_string_details(
105
+ key, default_value, evaluation_context
106
+ )
110
107
 
111
108
  def resolve_float_details(
112
109
  self,
113
110
  key: str,
114
- default_value: Number,
115
- evaluation_context: EvaluationContext = None,
116
- ):
117
- return self._resolve(key, FlagType.FLOAT, default_value, evaluation_context)
111
+ default_value: float,
112
+ evaluation_context: typing.Optional[EvaluationContext] = None,
113
+ ) -> FlagResolutionDetails[float]:
114
+ return self.resolver.resolve_float_details(
115
+ key, default_value, evaluation_context
116
+ )
118
117
 
119
118
  def resolve_integer_details(
120
119
  self,
121
120
  key: str,
122
- default_value: Number,
123
- evaluation_context: EvaluationContext = None,
124
- ):
125
- return self._resolve(key, FlagType.INTEGER, default_value, evaluation_context)
121
+ default_value: int,
122
+ evaluation_context: typing.Optional[EvaluationContext] = None,
123
+ ) -> FlagResolutionDetails[int]:
124
+ return self.resolver.resolve_integer_details(
125
+ key, default_value, evaluation_context
126
+ )
126
127
 
127
128
  def resolve_object_details(
128
129
  self,
129
130
  key: str,
130
131
  default_value: typing.Union[dict, list],
131
- evaluation_context: EvaluationContext = None,
132
- ):
133
- return self._resolve(key, FlagType.OBJECT, default_value, evaluation_context)
134
-
135
- def _resolve(
136
- self,
137
- flag_key: str,
138
- flag_type: FlagType,
139
- default_value: typing.Any,
140
- evaluation_context: EvaluationContext,
141
- ):
142
- context = self._convert_context(evaluation_context)
143
- try:
144
- if flag_type == FlagType.BOOLEAN:
145
- request = schema_pb2.ResolveBooleanRequest(
146
- flag_key=flag_key, context=context
147
- )
148
- response = self.stub.ResolveBoolean(request)
149
- elif flag_type == FlagType.STRING:
150
- request = schema_pb2.ResolveStringRequest(
151
- flag_key=flag_key, context=context
152
- )
153
- response = self.stub.ResolveString(request)
154
- elif flag_type == FlagType.OBJECT:
155
- request = schema_pb2.ResolveObjectRequest(
156
- flag_key=flag_key, context=context
157
- )
158
- response = self.stub.ResolveObject(request)
159
- elif flag_type == FlagType.FLOAT:
160
- request = schema_pb2.ResolveFloatRequest(
161
- flag_key=flag_key, context=context
162
- )
163
- response = self.stub.ResolveFloat(request)
164
- elif flag_type == FlagType.INTEGER:
165
- request = schema_pb2.ResolveIntRequest(
166
- flag_key=flag_key, context=context
167
- )
168
- response = self.stub.ResolveInt(request)
169
- else:
170
- raise ValueError(f"Unknown flag type: {flag_type}")
171
-
172
- except grpc.RpcError as e:
173
- code = e.code()
174
- message = f"received grpc status code {code}"
175
-
176
- if code == grpc.StatusCode.NOT_FOUND:
177
- raise FlagNotFoundError(message) from e
178
- elif code == grpc.StatusCode.INVALID_ARGUMENT:
179
- raise TypeMismatchError(message) from e
180
- elif code == grpc.StatusCode.DATA_LOSS:
181
- raise ParseError(message) from e
182
- raise GeneralError(message) from e
183
-
184
- # Got a valid flag and valid type. Return it.
185
- return FlagEvaluationDetails(
186
- flag_key=flag_key,
187
- value=response.value,
188
- reason=response.reason,
189
- variant=response.variant,
132
+ evaluation_context: typing.Optional[EvaluationContext] = None,
133
+ ) -> FlagResolutionDetails[typing.Union[dict, list]]:
134
+ return self.resolver.resolve_object_details(
135
+ key, default_value, evaluation_context
190
136
  )
191
-
192
- def _convert_context(self, evaluation_context: EvaluationContext):
193
- s = Struct()
194
- if evaluation_context:
195
- try:
196
- s.update(evaluation_context.attributes)
197
- except ValueError as exc:
198
- message = (
199
- "could not serialize evaluation context to google.protobuf.Struct"
200
- )
201
- raise InvalidContextError(message) from exc
202
- 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
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: openfeature-provider-flagd
3
- Version: 0.1.3
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/defaults.py,sha256=OeTt08wp_GUzpUetEJQvhSBGk9u4gTzACILQVqicbUw,102
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=2CZxQNJfcP8S5UJgDfwp6Q-znHRRaBpP7W7sGNS5lqk,6779
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.3.dist-info/METADATA,sha256=HCF4wFfmAKKkyOoHg_I3-1IHvAtbvnWhYkSbZUI1PRo,14518
14
- openfeature_provider_flagd-0.1.3.dist-info/WHEEL,sha256=TJPnKdtrSue7xZ_AVGkp9YXcvDrobsjBds1du3Nx6dc,87
15
- openfeature_provider_flagd-0.1.3.dist-info/licenses/LICENSE,sha256=h8jwqxShIeVkc8vOo9ynxGYW16f4fVPxLhZKZs0H5U8,11350
16
- openfeature_provider_flagd-0.1.3.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.21.1
2
+ Generator: hatchling 1.22.5
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,5 +0,0 @@
1
- class Defaults:
2
- HOST = "localhost"
3
- PORT = 8013
4
- SCHEMA = "http"
5
- TIMEOUT = 2 # seconds