launchdarkly-openfeature-server 0.1.0__tar.gz

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,13 @@
1
+ Copyright 2024 Catamorphic, Co.
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
@@ -0,0 +1,189 @@
1
+ Metadata-Version: 2.1
2
+ Name: launchdarkly-openfeature-server
3
+ Version: 0.1.0
4
+ Summary: An OpenFeature provider for the LaunchDarkly Python server SDK
5
+ Home-page: https://github.com/launchdarkly/openfeature-python-server
6
+ License: Apache-2.0
7
+ Author: LaunchDarkly
8
+ Author-email: dev@launchdarkly.com
9
+ Requires-Python: >=3.8,<4.0
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.8
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Requires-Dist: launchdarkly-server-sdk (<10)
22
+ Requires-Dist: openfeature-sdk (==0.4.2)
23
+ Project-URL: Documentation, https://launchdarkly-openfeature-server.readthedocs.io/en/latest/
24
+ Project-URL: Repository, https://github.com/launchdarkly/openfeature-python-server
25
+ Description-Content-Type: text/markdown
26
+
27
+ # LaunchDarkly OpenFeature provider for the Server-Side SDK for Python
28
+
29
+ [![Quality control checks](https://github.com/launchdarkly/openfeature-python-server/actions/workflows/ci.yml/badge.svg)](https://github.com/launchdarkly/openfeature-python-server/actions/workflows/ci.yml)
30
+ [![Packagist](https://img.shields.io/packagist/v/launchdarkly/openfeature-server.svg?style=flat-square)](https://packagist.org/packages/launchdarkly/openfeature-server)
31
+ [![readthedocs](https://readthedocs.org/projects/launchdarkly-openfeature-server/badge/)](https://launchdarkly-openfeature-server.readthedocs.io/en/latest/)
32
+
33
+ This provider allows for using LaunchDarkly with the OpenFeature SDK for Python.
34
+
35
+ This provider is designed primarily for use in multi-user systems such as web servers and applications. It follows the server-side LaunchDarkly model for multi-user contexts. It is not intended for use in desktop and embedded systems applications.
36
+
37
+ This provider is a beta version and should not be considered ready for production use while this message is visible.
38
+
39
+ # LaunchDarkly overview
40
+
41
+ [LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves trillions of feature flags daily to help teams build better software, faster. [Get started](https://docs.launchdarkly.com/home/getting-started) using LaunchDarkly today!
42
+
43
+ [![Twitter Follow](https://img.shields.io/twitter/follow/launchdarkly.svg?style=social&label=Follow&maxAge=2592000)](https://twitter.com/intent/follow?screen_name=launchdarkly)
44
+
45
+ ## Supported Python versions
46
+
47
+ This version of the LaunchDarkly provider works with Python 3.8 and above.
48
+
49
+ ## Getting started
50
+
51
+ ### Requisites
52
+
53
+ Install the library via pip
54
+
55
+ ```shell
56
+ $ pip install launchdarkly-openfeature-server
57
+ ```
58
+
59
+ ### Usage
60
+
61
+ ```python
62
+ from ldclient import Config, LDClient
63
+ from ld_openfeature import LaunchDarklyProvider
64
+ from openfeature.evaluation_context import EvaluationContext
65
+ from openfeature import api
66
+
67
+ ld_client = LDClient(config=Config("sdk-key"))
68
+ openfeature_provider = LaunchDarklyProvider(ld_client)
69
+
70
+ api.set_provider(openfeature_provider)
71
+
72
+ # Refer to OpenFeature documentation for getting a client and performing evaluations.
73
+ ```
74
+
75
+ Refer to the [SDK reference guide](https://docs.launchdarkly.com/sdk/server-side/python) for instructions on getting started with using the SDK.
76
+
77
+ For information on using the OpenFeature client please refer to the [OpenFeature Documentation](https://docs.openfeature.dev/docs/reference/concepts/evaluation-api/).
78
+
79
+ ## OpenFeature Specific Considerations
80
+
81
+ LaunchDarkly evaluates contexts, and it can either evaluate a single-context, or a multi-context. When using OpenFeature both single and multi-contexts must be encoded into a single `EvaluationContext`. This is accomplished by looking for an attribute named `kind` in the `EvaluationContext`.
82
+
83
+ There are 4 different scenarios related to the `kind`:
84
+ 1. There is no `kind` attribute. In this case the provider will treat the context as a single context containing a "user" kind.
85
+ 2. There is a `kind` attribute, and the value of that attribute is "multi". This will indicate to the provider that the context is a multi-context.
86
+ 3. There is a `kind` attribute, and the value of that attribute is a string other than "multi". This will indicate to the provider a single context of the kind specified.
87
+ 4. There is a `kind` attribute, and the attribute is not a string. In this case the value of the attribute will be discarded, and the context will be treated as a "user". An error message will be logged.
88
+
89
+ The `kind` attribute should be a string containing only contain ASCII letters, numbers, `.`, `_` or `-`.
90
+
91
+ The OpenFeature specification allows for an optional targeting key, but LaunchDarkly requires a key for evaluation. A targeting key must be specified for each context being evaluated. It may be specified using either `targetingKey`, as it is in the OpenFeature specification, or `key`, which is the typical LaunchDarkly identifier for the targeting key. If a `targetingKey` and a `key` are specified, then the `targetingKey` will take precedence.
92
+
93
+ There are several other attributes which have special functionality within a single or multi-context.
94
+ - A key of `privateAttributes`. Must be an array of string values. [Equivalent to the 'private' builder method in the SDK.](https://launchdarkly-python-sdk.readthedocs.io/en/latest/api-main.html#ldclient.ContextBuilder.private)
95
+ - A key of `anonymous`. Must be a boolean value. [Equivalent to the 'anonymous' builder method in the SDK.](https://launchdarkly-python-sdk.readthedocs.io/en/latest/api-main.html#ldclient.ContextBuilder.anonymous)
96
+ - A key of `name`. Must be a string. [Equivalent to the 'name' builder method in the SDK.](https://launchdarkly-python-sdk.readthedocs.io/en/latest/api-main.html#ldclient.ContextBuilder.name)
97
+
98
+ ### Examples
99
+
100
+ #### A single user context
101
+
102
+ ```python
103
+ context = EvaluationContext("the-key")
104
+ ```
105
+
106
+ #### A single context of kind "organization"
107
+
108
+ ```python
109
+ context = EvaluationContext("org-key", {"kind": "organization"});
110
+ ```
111
+
112
+ #### A multi-context containing a "user" and an "organization"
113
+
114
+ ```python
115
+ attributes = {
116
+ "kind": "multi",
117
+ "organization": {
118
+ "name": "the-org-name",
119
+ "targetingKey", "my-org-key",
120
+ "myCustomAttribute", "myAttributeValue"
121
+ },
122
+ "user": {
123
+ "key": "my-user-key",
124
+ "anonymous", true
125
+ }
126
+ }
127
+ context = EvaluationContext(null, attributes)
128
+ ```
129
+
130
+ #### Setting private attributes in a single context
131
+
132
+ ```python
133
+ attributes = {
134
+ "kind": "organization",
135
+ "myCustomAttribute": "myAttributeValue",
136
+ "privateAttributes": ["myCustomAttribute"]
137
+ }
138
+
139
+ context = EvaluationContext("org-key", attributes)
140
+ ```
141
+
142
+ #### Setting private attributes in a multi-context
143
+
144
+ ```python
145
+ attributes = {
146
+ "kind": "organization",
147
+ "organization": {
148
+ "name": "the-org-name",
149
+ "targetingKey": "my-org-key",
150
+ # This will ONLY apply to the "organization" attributes.
151
+ "privateAttributes": ["myCustomAttribute"],
152
+ # This attribute will be private.
153
+ "myCustomAttribute": "myAttributeValue",
154
+ },
155
+ "user": [
156
+ "key": "my-user-key",
157
+ "anonymous" = > true,
158
+ # This attribute will not be private.
159
+ "myCustomAttribute": "myAttributeValue",
160
+ ]
161
+ }
162
+
163
+ context = EvaluationContext(null, attributes)
164
+ ```
165
+
166
+ ## Learn more
167
+
168
+ Check out our [documentation](http://docs.launchdarkly.com) for in-depth instructions on configuring and using LaunchDarkly. You can also head straight to the [complete reference guide for this SDK](https://docs.launchdarkly.com/sdk/server-side/python).
169
+
170
+ The authoritative description of all properties and methods is in the [python documentation](https://launchdarkly.github.io/python-server-sdk/).
171
+
172
+ ## Contributing
173
+
174
+ We encourage pull requests and other contributions from the community. Check out our [contributing guidelines](CONTRIBUTING.md) for instructions on how to contribute to this SDK.
175
+
176
+ ## About LaunchDarkly
177
+
178
+ * LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can:
179
+ * Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases.
180
+ * Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?).
181
+ * Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file.
182
+ * Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get access to more features than users in the ‘silver’ plan). Disable parts of your application to facilitate maintenance, without taking everything offline.
183
+ * LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Check out [our documentation](https://docs.launchdarkly.com/sdk) for a complete list.
184
+ * Explore LaunchDarkly
185
+ * [launchdarkly.com](https://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information
186
+ * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides
187
+ * [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation
188
+ * [blog.launchdarkly.com](https://blog.launchdarkly.com/ "LaunchDarkly Blog Documentation") for the latest product updates
189
+
@@ -0,0 +1,162 @@
1
+ # LaunchDarkly OpenFeature provider for the Server-Side SDK for Python
2
+
3
+ [![Quality control checks](https://github.com/launchdarkly/openfeature-python-server/actions/workflows/ci.yml/badge.svg)](https://github.com/launchdarkly/openfeature-python-server/actions/workflows/ci.yml)
4
+ [![Packagist](https://img.shields.io/packagist/v/launchdarkly/openfeature-server.svg?style=flat-square)](https://packagist.org/packages/launchdarkly/openfeature-server)
5
+ [![readthedocs](https://readthedocs.org/projects/launchdarkly-openfeature-server/badge/)](https://launchdarkly-openfeature-server.readthedocs.io/en/latest/)
6
+
7
+ This provider allows for using LaunchDarkly with the OpenFeature SDK for Python.
8
+
9
+ This provider is designed primarily for use in multi-user systems such as web servers and applications. It follows the server-side LaunchDarkly model for multi-user contexts. It is not intended for use in desktop and embedded systems applications.
10
+
11
+ This provider is a beta version and should not be considered ready for production use while this message is visible.
12
+
13
+ # LaunchDarkly overview
14
+
15
+ [LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves trillions of feature flags daily to help teams build better software, faster. [Get started](https://docs.launchdarkly.com/home/getting-started) using LaunchDarkly today!
16
+
17
+ [![Twitter Follow](https://img.shields.io/twitter/follow/launchdarkly.svg?style=social&label=Follow&maxAge=2592000)](https://twitter.com/intent/follow?screen_name=launchdarkly)
18
+
19
+ ## Supported Python versions
20
+
21
+ This version of the LaunchDarkly provider works with Python 3.8 and above.
22
+
23
+ ## Getting started
24
+
25
+ ### Requisites
26
+
27
+ Install the library via pip
28
+
29
+ ```shell
30
+ $ pip install launchdarkly-openfeature-server
31
+ ```
32
+
33
+ ### Usage
34
+
35
+ ```python
36
+ from ldclient import Config, LDClient
37
+ from ld_openfeature import LaunchDarklyProvider
38
+ from openfeature.evaluation_context import EvaluationContext
39
+ from openfeature import api
40
+
41
+ ld_client = LDClient(config=Config("sdk-key"))
42
+ openfeature_provider = LaunchDarklyProvider(ld_client)
43
+
44
+ api.set_provider(openfeature_provider)
45
+
46
+ # Refer to OpenFeature documentation for getting a client and performing evaluations.
47
+ ```
48
+
49
+ Refer to the [SDK reference guide](https://docs.launchdarkly.com/sdk/server-side/python) for instructions on getting started with using the SDK.
50
+
51
+ For information on using the OpenFeature client please refer to the [OpenFeature Documentation](https://docs.openfeature.dev/docs/reference/concepts/evaluation-api/).
52
+
53
+ ## OpenFeature Specific Considerations
54
+
55
+ LaunchDarkly evaluates contexts, and it can either evaluate a single-context, or a multi-context. When using OpenFeature both single and multi-contexts must be encoded into a single `EvaluationContext`. This is accomplished by looking for an attribute named `kind` in the `EvaluationContext`.
56
+
57
+ There are 4 different scenarios related to the `kind`:
58
+ 1. There is no `kind` attribute. In this case the provider will treat the context as a single context containing a "user" kind.
59
+ 2. There is a `kind` attribute, and the value of that attribute is "multi". This will indicate to the provider that the context is a multi-context.
60
+ 3. There is a `kind` attribute, and the value of that attribute is a string other than "multi". This will indicate to the provider a single context of the kind specified.
61
+ 4. There is a `kind` attribute, and the attribute is not a string. In this case the value of the attribute will be discarded, and the context will be treated as a "user". An error message will be logged.
62
+
63
+ The `kind` attribute should be a string containing only contain ASCII letters, numbers, `.`, `_` or `-`.
64
+
65
+ The OpenFeature specification allows for an optional targeting key, but LaunchDarkly requires a key for evaluation. A targeting key must be specified for each context being evaluated. It may be specified using either `targetingKey`, as it is in the OpenFeature specification, or `key`, which is the typical LaunchDarkly identifier for the targeting key. If a `targetingKey` and a `key` are specified, then the `targetingKey` will take precedence.
66
+
67
+ There are several other attributes which have special functionality within a single or multi-context.
68
+ - A key of `privateAttributes`. Must be an array of string values. [Equivalent to the 'private' builder method in the SDK.](https://launchdarkly-python-sdk.readthedocs.io/en/latest/api-main.html#ldclient.ContextBuilder.private)
69
+ - A key of `anonymous`. Must be a boolean value. [Equivalent to the 'anonymous' builder method in the SDK.](https://launchdarkly-python-sdk.readthedocs.io/en/latest/api-main.html#ldclient.ContextBuilder.anonymous)
70
+ - A key of `name`. Must be a string. [Equivalent to the 'name' builder method in the SDK.](https://launchdarkly-python-sdk.readthedocs.io/en/latest/api-main.html#ldclient.ContextBuilder.name)
71
+
72
+ ### Examples
73
+
74
+ #### A single user context
75
+
76
+ ```python
77
+ context = EvaluationContext("the-key")
78
+ ```
79
+
80
+ #### A single context of kind "organization"
81
+
82
+ ```python
83
+ context = EvaluationContext("org-key", {"kind": "organization"});
84
+ ```
85
+
86
+ #### A multi-context containing a "user" and an "organization"
87
+
88
+ ```python
89
+ attributes = {
90
+ "kind": "multi",
91
+ "organization": {
92
+ "name": "the-org-name",
93
+ "targetingKey", "my-org-key",
94
+ "myCustomAttribute", "myAttributeValue"
95
+ },
96
+ "user": {
97
+ "key": "my-user-key",
98
+ "anonymous", true
99
+ }
100
+ }
101
+ context = EvaluationContext(null, attributes)
102
+ ```
103
+
104
+ #### Setting private attributes in a single context
105
+
106
+ ```python
107
+ attributes = {
108
+ "kind": "organization",
109
+ "myCustomAttribute": "myAttributeValue",
110
+ "privateAttributes": ["myCustomAttribute"]
111
+ }
112
+
113
+ context = EvaluationContext("org-key", attributes)
114
+ ```
115
+
116
+ #### Setting private attributes in a multi-context
117
+
118
+ ```python
119
+ attributes = {
120
+ "kind": "organization",
121
+ "organization": {
122
+ "name": "the-org-name",
123
+ "targetingKey": "my-org-key",
124
+ # This will ONLY apply to the "organization" attributes.
125
+ "privateAttributes": ["myCustomAttribute"],
126
+ # This attribute will be private.
127
+ "myCustomAttribute": "myAttributeValue",
128
+ },
129
+ "user": [
130
+ "key": "my-user-key",
131
+ "anonymous" = > true,
132
+ # This attribute will not be private.
133
+ "myCustomAttribute": "myAttributeValue",
134
+ ]
135
+ }
136
+
137
+ context = EvaluationContext(null, attributes)
138
+ ```
139
+
140
+ ## Learn more
141
+
142
+ Check out our [documentation](http://docs.launchdarkly.com) for in-depth instructions on configuring and using LaunchDarkly. You can also head straight to the [complete reference guide for this SDK](https://docs.launchdarkly.com/sdk/server-side/python).
143
+
144
+ The authoritative description of all properties and methods is in the [python documentation](https://launchdarkly.github.io/python-server-sdk/).
145
+
146
+ ## Contributing
147
+
148
+ We encourage pull requests and other contributions from the community. Check out our [contributing guidelines](CONTRIBUTING.md) for instructions on how to contribute to this SDK.
149
+
150
+ ## About LaunchDarkly
151
+
152
+ * LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can:
153
+ * Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases.
154
+ * Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?).
155
+ * Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file.
156
+ * Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get access to more features than users in the ‘silver’ plan). Disable parts of your application to facilitate maintenance, without taking everything offline.
157
+ * LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Check out [our documentation](https://docs.launchdarkly.com/sdk) for a complete list.
158
+ * Explore LaunchDarkly
159
+ * [launchdarkly.com](https://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information
160
+ * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides
161
+ * [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation
162
+ * [blog.launchdarkly.com](https://blog.launchdarkly.com/ "LaunchDarkly Blog Documentation") for the latest product updates
@@ -0,0 +1,5 @@
1
+ from ld_openfeature.provider import LaunchDarklyProvider
2
+
3
+ __all__ = [
4
+ 'LaunchDarklyProvider'
5
+ ]
@@ -0,0 +1,113 @@
1
+ from logging import getLogger
2
+ from typing import Any, Dict, List, Optional
3
+
4
+ from ldclient.context import Context, ContextBuilder, ContextMultiBuilder
5
+ from openfeature.provider.provider import EvaluationContext
6
+
7
+
8
+ logger = getLogger("launchdarkly-openfeature-server")
9
+
10
+
11
+ class EvaluationContextConverter:
12
+ def to_ld_context(self, context: EvaluationContext) -> Context:
13
+ """
14
+ Create an Context from an EvaluationContext.
15
+
16
+ A context will always be created, but the created context may be
17
+ invalid. Log messages will be written to indicate the source of the
18
+ problem.
19
+ """
20
+ attributes = context.attributes
21
+
22
+ kind = attributes.get('kind')
23
+ if kind == "multi":
24
+ return self.__build_multi_context(context)
25
+
26
+ if kind is not None and not isinstance(kind, str):
27
+ logger.warning("'kind' was set to a non-string value; defaulting to user")
28
+ kind = 'user'
29
+
30
+ targeting_key = context.targeting_key
31
+ key = attributes.get('key')
32
+ targeting_key = self.__get_targeting_key(targeting_key, key)
33
+
34
+ kind = "user" if kind is None else kind
35
+ return self.__build_single_context(attributes, kind, targeting_key)
36
+
37
+ def __get_targeting_key(self, targeting_key: Optional[str], key: Any) -> str:
38
+ # The targeting key may be set but empty. So we want to treat an empty
39
+ # string as a not defined one. Later it could become null, so we will
40
+ # need to check that.
41
+ if targeting_key is not None and targeting_key != "" and isinstance(key, str):
42
+ # There is both a targeting key and a key. It will work, but
43
+ # probably is not intentional.
44
+ logger.warning("EvaluationContext contained both a 'key' and 'targetingKey'.")
45
+
46
+ if key is not None and not isinstance(key, str):
47
+ logger.warning("A non-string 'key' attribute was provided.")
48
+
49
+ if key is not None and isinstance(key, str):
50
+ targeting_key = targeting_key if targeting_key else key
51
+
52
+ if targeting_key is None or targeting_key == "":
53
+ logger.error("The EvaluationContext must contain either a 'targetingKey' or a 'key' and the type must be a string.")
54
+
55
+ return targeting_key if targeting_key else ""
56
+
57
+ def __build_multi_context(self, context: EvaluationContext) -> Context:
58
+ builder = ContextMultiBuilder()
59
+
60
+ for kind, attributes in context.attributes.items():
61
+ if kind == 'kind':
62
+ continue
63
+
64
+ if not isinstance(attributes, Dict):
65
+ logger.warning("Top level attributes in a multi-kind context should be dictionaries")
66
+ continue
67
+
68
+ key = attributes.get('key')
69
+ targeting_key = attributes.get('targetingKey')
70
+
71
+ if targeting_key is not None and not isinstance(targeting_key, str):
72
+ continue
73
+
74
+ targeting_key = self.__get_targeting_key(targeting_key, key)
75
+ single_context = self.__build_single_context(attributes, kind, targeting_key)
76
+
77
+ builder.add(single_context)
78
+
79
+ return builder.build()
80
+
81
+ def __build_single_context(self, attributes: Dict, kind: str, key: str) -> Context:
82
+ builder = ContextBuilder(key)
83
+ builder.kind(kind)
84
+
85
+ for k, v in attributes.items():
86
+ if k == 'key' or k == 'targetingKey':
87
+ continue
88
+
89
+ if k == 'name' and isinstance(v, str):
90
+ builder.name(v)
91
+ elif k == 'name':
92
+ logger.error("The attribute 'name' must be a string")
93
+ elif k == 'anonymous' and isinstance(v, bool):
94
+ builder.anonymous(v)
95
+ elif k == 'anonymous':
96
+ logger.error("The attribute 'anonymous' must be a boolean")
97
+ elif k == 'privateAttributes' and isinstance(v, list):
98
+ private_attributes: List[str] = []
99
+ for private_attribute in v:
100
+ if not isinstance(private_attribute, str):
101
+ logger.error("'privateAttributes' must be an array of only string values")
102
+ continue
103
+
104
+ private_attributes.append(private_attribute)
105
+
106
+ if private_attributes:
107
+ builder.private(*private_attributes)
108
+ elif k == 'privateAttributes':
109
+ logger.error("The attribute 'privateAttributes' must be an array")
110
+ else:
111
+ builder.set(k, v)
112
+
113
+ return builder.build()
@@ -0,0 +1,68 @@
1
+ from typing import Optional
2
+
3
+ from ldclient.evaluation import EvaluationDetail
4
+ from openfeature.exception import ErrorCode
5
+ from openfeature.flag_evaluation import FlagResolutionDetails, Reason
6
+
7
+
8
+ class ResolutionDetailsConverter:
9
+ def to_resolution_details(self, result: EvaluationDetail) -> FlagResolutionDetails:
10
+ value = result.value
11
+ is_default = result.is_default_value()
12
+ variation_index = result.variation_index
13
+
14
+ reason = result.reason
15
+ reason_kind = reason.get('kind')
16
+ reason_kind = reason_kind if isinstance(reason_kind, str) else ''
17
+
18
+ openfeature_reason = self.__kind_to_reason(reason_kind)
19
+
20
+ openfeature_error_code: Optional[ErrorCode] = None
21
+ if reason_kind == "ERROR":
22
+ openfeature_error_code = self.__error_kind_to_code(reason.get('errorKind'))
23
+
24
+ openfeature_variant: Optional[str] = None
25
+ if not is_default:
26
+ openfeature_variant = str(variation_index)
27
+
28
+ return FlagResolutionDetails(
29
+ value=value,
30
+ error_code=openfeature_error_code,
31
+ error_message=None,
32
+ reason=openfeature_reason,
33
+ variant=openfeature_variant
34
+ # flag_metadata = FlagMetadata = field(default_factory=dict)
35
+ )
36
+ pass
37
+
38
+ @staticmethod
39
+ def __kind_to_reason(kind: str) -> str:
40
+ if kind == 'OFF':
41
+ return Reason.DISABLED
42
+ elif kind == 'TARGET_MATCH':
43
+ return Reason.TARGETING_MATCH
44
+ elif kind == 'ERROR':
45
+ return Reason.ERROR
46
+
47
+ # NOTE: FALLTHROUGH, RULE_MATCH, PREREQUISITE_FAILED intentionally
48
+ # omitted
49
+
50
+ return kind
51
+
52
+ @staticmethod
53
+ def __error_kind_to_code(error_kind: Optional[str]) -> ErrorCode:
54
+ if error_kind is None:
55
+ return ErrorCode.GENERAL
56
+
57
+ if error_kind == 'CLIENT_NOT_READY':
58
+ return ErrorCode.PROVIDER_NOT_READY
59
+ elif error_kind == 'FLAG_NOT_FOUND':
60
+ return ErrorCode.FLAG_NOT_FOUND
61
+ elif error_kind == 'MALFORMED_FLAG':
62
+ return ErrorCode.PARSE_ERROR
63
+ elif error_kind == 'USER_NOT_SPECIFIED':
64
+ return ErrorCode.TARGETING_KEY_MISSING
65
+
66
+ # NOTE: EXCEPTION_ERROR intentionally omitted
67
+
68
+ return ErrorCode.GENERAL
@@ -0,0 +1,111 @@
1
+ from typing import Any, List, Optional, Union
2
+
3
+ from ldclient import LDClient, Config
4
+ from openfeature.evaluation_context import EvaluationContext
5
+ from openfeature.exception import ErrorCode
6
+ from openfeature.flag_evaluation import FlagResolutionDetails, FlagType, Reason
7
+ from openfeature.hook import Hook
8
+ from openfeature.provider.metadata import Metadata
9
+ from openfeature.provider.provider import AbstractProvider
10
+
11
+ from ld_openfeature.impl.context_converter import EvaluationContextConverter
12
+ from ld_openfeature.impl.details_converter import ResolutionDetailsConverter
13
+
14
+
15
+ class LaunchDarklyProvider(AbstractProvider):
16
+ def __init__(self, config: Config):
17
+ self.__client = LDClient(config)
18
+
19
+ self.__context_converter = EvaluationContextConverter()
20
+ self.__details_converter = ResolutionDetailsConverter()
21
+
22
+ def shutdown(self):
23
+ self.__client.close()
24
+
25
+ def get_metadata(self) -> Metadata:
26
+ return Metadata("launchdarkly-openfeature-server")
27
+
28
+ def get_provider_hooks(self) -> List[Hook]:
29
+ return []
30
+
31
+ def resolve_boolean_details(
32
+ self,
33
+ flag_key: str,
34
+ default_value: bool,
35
+ evaluation_context: Optional[EvaluationContext] = None,
36
+ ) -> FlagResolutionDetails[bool]:
37
+ """Resolves the flag value for the provided flag key as a boolean"""
38
+ return self.__resolve_value(FlagType(FlagType.BOOLEAN), flag_key, default_value, evaluation_context)
39
+
40
+ def resolve_string_details(
41
+ self,
42
+ flag_key: str,
43
+ default_value: str,
44
+ evaluation_context: Optional[EvaluationContext] = None,
45
+ ) -> FlagResolutionDetails[str]:
46
+ """Resolves the flag value for the provided flag key as a string"""
47
+ return self.__resolve_value(FlagType(FlagType.STRING), flag_key, default_value, evaluation_context)
48
+
49
+ def resolve_integer_details(
50
+ self,
51
+ flag_key: str,
52
+ default_value: int,
53
+ evaluation_context: Optional[EvaluationContext] = None,
54
+ ) -> FlagResolutionDetails[int]:
55
+ """Resolves the flag value for the provided flag key as a integer"""
56
+ return self.__resolve_value(FlagType(FlagType.INTEGER), flag_key, default_value, evaluation_context)
57
+
58
+ def resolve_float_details(
59
+ self,
60
+ flag_key: str,
61
+ default_value: float,
62
+ evaluation_context: Optional[EvaluationContext] = None,
63
+ ) -> FlagResolutionDetails[float]:
64
+ """Resolves the flag value for the provided flag key as a float"""
65
+ return self.__resolve_value(FlagType(FlagType.FLOAT), flag_key, default_value, evaluation_context)
66
+
67
+ def resolve_object_details(
68
+ self,
69
+ flag_key: str,
70
+ default_value: Union[dict, list],
71
+ evaluation_context: Optional[EvaluationContext] = None,
72
+ ) -> FlagResolutionDetails[Union[dict, list]]:
73
+ """Resolves the flag value for the provided flag key as a list or dictionary"""
74
+ return self.__resolve_value(FlagType(FlagType.OBJECT), flag_key, default_value, evaluation_context)
75
+
76
+ def __resolve_value(self, flag_type: FlagType, flag_key: str, default_value: Any, evaluation_context: Optional[EvaluationContext] = None) -> FlagResolutionDetails:
77
+ if evaluation_context is None:
78
+ return FlagResolutionDetails(
79
+ value=default_value,
80
+ reason=Reason(Reason.ERROR),
81
+ error_code=ErrorCode.TARGETING_KEY_MISSING
82
+ )
83
+
84
+ ld_context = self.__context_converter.to_ld_context(evaluation_context)
85
+ result = self.__client.variation_detail(flag_key, ld_context, default_value)
86
+
87
+ if flag_type == FlagType.BOOLEAN and not isinstance(result.value, bool):
88
+ return self.__mismatched_type_details(default_value)
89
+ elif flag_type == FlagType.STRING and not isinstance(result.value, str):
90
+ return self.__mismatched_type_details(default_value)
91
+ elif flag_type == FlagType.INTEGER and isinstance(result.value, bool):
92
+ # Python treats boolean values as instances of int
93
+ return self.__mismatched_type_details(default_value)
94
+ elif flag_type == FlagType.FLOAT and isinstance(result.value, bool):
95
+ # Python treats boolean values as instances of int
96
+ return self.__mismatched_type_details(default_value)
97
+ elif flag_type == FlagType.INTEGER and not isinstance(result.value, int):
98
+ return self.__mismatched_type_details(default_value)
99
+ elif flag_type == FlagType.FLOAT and not isinstance(result.value, float) and not isinstance(result.value, int):
100
+ return self.__mismatched_type_details(default_value)
101
+ elif flag_type == FlagType.OBJECT and not isinstance(result.value, dict) and not isinstance(result.value, list):
102
+ return self.__mismatched_type_details(default_value)
103
+
104
+ return self.__details_converter.to_resolution_details(result)
105
+
106
+ def __mismatched_type_details(self, default_value: Any) -> FlagResolutionDetails:
107
+ return FlagResolutionDetails(
108
+ value=default_value,
109
+ reason=Reason(Reason.ERROR),
110
+ error_code=ErrorCode.TYPE_MISMATCH
111
+ )
@@ -0,0 +1,74 @@
1
+ [tool.poetry]
2
+ name = "launchdarkly-openfeature-server"
3
+ version = "0.1.0"
4
+ description = "An OpenFeature provider for the LaunchDarkly Python server SDK"
5
+ authors = ["LaunchDarkly <dev@launchdarkly.com>"]
6
+ license = "Apache-2.0"
7
+ readme = "README.md"
8
+ repository = "https://github.com/launchdarkly/openfeature-python-server"
9
+ documentation = "https://launchdarkly-openfeature-server.readthedocs.io/en/latest/"
10
+ classifiers = [
11
+ "Intended Audience :: Developers",
12
+ "License :: OSI Approved :: Apache Software License",
13
+ "Operating System :: OS Independent",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.8",
16
+ "Programming Language :: Python :: 3.9",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Topic :: Software Development",
21
+ "Topic :: Software Development :: Libraries",
22
+ ]
23
+ packages = [
24
+ { include = "ld_openfeature" },
25
+ { include = "tests" },
26
+ ]
27
+
28
+
29
+ [tool.poetry.dependencies]
30
+ python = "^3.8"
31
+ openfeature-sdk = "0.4.2"
32
+ launchdarkly-server-sdk = "<10"
33
+
34
+
35
+ [tool.poetry.group.dev.dependencies]
36
+ pytest = ">=2.8"
37
+ pytest-cov = ">=2.4.0"
38
+ pytest-mypy = "==0.10.3"
39
+ mypy = "==1.8.0"
40
+ isort = "^5.13.2"
41
+
42
+
43
+ [tool.poetry.group.docs]
44
+ optional = true
45
+
46
+ [tool.poetry.group.docs.dependencies]
47
+ sphinx = "^6.0.0"
48
+ sphinx-rtd-theme = ">=1.3,<3.0"
49
+ certifi = ">=2018.4.16"
50
+ expiringdict = ">=1.1.4"
51
+ pyrfc3339 = ">=1.0"
52
+ jsonpickle = ">1.4.1"
53
+ semver = ">=2.7.9"
54
+ urllib3 = ">=1.22.0"
55
+ jinja2 = "3.1.3"
56
+
57
+
58
+ [tool.mypy]
59
+ python_version = "3.8"
60
+ install_types = true
61
+ non_interactive = true
62
+
63
+
64
+ [tool.isort]
65
+ py_version=38
66
+
67
+
68
+ [tool.pytest.ini_options]
69
+ addopts = ["-ra"]
70
+
71
+
72
+ [build-system]
73
+ requires = ["poetry-core"]
74
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,185 @@
1
+ import pytest
2
+ from openfeature.evaluation_context import EvaluationContext
3
+
4
+ from ld_openfeature.impl.context_converter import EvaluationContextConverter
5
+
6
+
7
+ @pytest.fixture
8
+ def context_converter() -> EvaluationContextConverter:
9
+ return EvaluationContextConverter()
10
+
11
+
12
+ def test_create_context_with_only_targeting_key(context_converter: EvaluationContextConverter):
13
+ context = EvaluationContext("user-key")
14
+ ld_context = context_converter.to_ld_context(context)
15
+
16
+ assert ld_context.valid is True
17
+ assert ld_context.key == 'user-key'
18
+ assert ld_context.kind == 'user'
19
+
20
+
21
+ def test_create_context_with_invalid_key(context_converter: EvaluationContextConverter, caplog):
22
+ context = EvaluationContext(None, {"key": False})
23
+ ld_context = context_converter.to_ld_context(context)
24
+
25
+ assert ld_context.valid is False
26
+ assert ld_context.key == ''
27
+
28
+ assert caplog.records[0].message == "A non-string 'key' attribute was provided."
29
+
30
+
31
+ def test_invalid_private_attribute_types_are_ignored(context_converter: EvaluationContextConverter, caplog):
32
+ context = EvaluationContext("user-key", {"privateAttributes": [True]})
33
+ ld_context = context_converter.to_ld_context(context)
34
+
35
+ assert ld_context.valid is True
36
+ assert ld_context.key == 'user-key'
37
+
38
+ assert caplog.records[0].message == "'privateAttributes' must be an array of only string values"
39
+
40
+
41
+ def test_create_multi_context_with_invalid_targeting_key(context_converter: EvaluationContextConverter):
42
+ attributes = {
43
+ 'kind': 'multi',
44
+ 'user': {'targetingKey': False, 'key': 'user-key', 'name': 'User name'},
45
+ 'org': {'key': 'org-key', 'name': 'Org name'},
46
+ }
47
+ context = EvaluationContext(None, attributes)
48
+ ld_context = context_converter.to_ld_context(context)
49
+
50
+ assert ld_context.valid is True
51
+ assert ld_context.key == 'org-key'
52
+ assert ld_context.kind == 'org'
53
+
54
+
55
+ def test_create_context_with_only_key(context_converter: EvaluationContextConverter):
56
+ context = EvaluationContext(None, {"key": "user-key"})
57
+ ld_context = context_converter.to_ld_context(context)
58
+
59
+ assert ld_context.valid is True
60
+ assert ld_context.key == 'user-key'
61
+ assert ld_context.kind == 'user'
62
+
63
+
64
+ def test_targeting_key_takes_precedence_over_attribute_key(context_converter: EvaluationContextConverter):
65
+ context = EvaluationContext("should-use", {"kind": "org", "key": "do-not-use"})
66
+ ld_context = context_converter.to_ld_context(context)
67
+
68
+ assert ld_context.valid is True
69
+ assert ld_context.key == 'should-use'
70
+ assert ld_context.kind == 'org'
71
+
72
+
73
+ def test_targeting_key_in_attributes_is_ignored(context_converter: EvaluationContextConverter):
74
+ context = EvaluationContext("should-use", {"kind": "org", "key": "do-not-use", "targetingKey": "also-do-not-use"})
75
+ ld_context = context_converter.to_ld_context(context)
76
+
77
+ assert ld_context.valid is True
78
+ assert ld_context.key == 'should-use'
79
+ assert ld_context.kind == 'org'
80
+
81
+
82
+ def test_create_context_with_key_and_kind(context_converter: EvaluationContextConverter):
83
+ context = EvaluationContext("org-key", {"kind": "org"})
84
+ ld_context = context_converter.to_ld_context(context)
85
+
86
+ assert ld_context.valid is True
87
+ assert ld_context.key == 'org-key'
88
+ assert ld_context.kind == 'org'
89
+
90
+
91
+ def test_invalid_kind_resets_to_user(context_converter: EvaluationContextConverter):
92
+ context = EvaluationContext("org-key", {"kind": False})
93
+ ld_context = context_converter.to_ld_context(context)
94
+
95
+ assert ld_context.valid is True
96
+ assert ld_context.key == 'org-key'
97
+ assert ld_context.kind == 'user'
98
+
99
+
100
+ def test_attributes_are_referenced_correctly(context_converter: EvaluationContextConverter):
101
+ context = EvaluationContext("user-key", {"kind": "user", "anonymous": True, "name": "Sandy", "lastName": "Beaches"})
102
+ ld_context = context_converter.to_ld_context(context)
103
+
104
+ assert ld_context.valid is True
105
+ assert ld_context.key == 'user-key'
106
+ assert ld_context.kind == 'user'
107
+ assert ld_context.anonymous is True
108
+ assert ld_context.name == 'Sandy'
109
+ assert ld_context.get('lastName') == 'Beaches'
110
+
111
+
112
+ def test_invalid_attributes_are_ignored(context_converter: EvaluationContextConverter):
113
+ context = EvaluationContext("user-key", {"kind": "user", "anonymous": "True", "name": 30, "privateAttributes": "testing"})
114
+ ld_context = context_converter.to_ld_context(context)
115
+
116
+ assert ld_context.valid is True
117
+ assert ld_context.key == 'user-key'
118
+ assert ld_context.kind == 'user'
119
+ assert ld_context.anonymous is False
120
+ assert ld_context.name is None
121
+ assert ld_context.private_attributes == ()
122
+
123
+
124
+ def test_private_attributes_are_processed_correctly(context_converter: EvaluationContextConverter):
125
+ context = EvaluationContext("user-key", {"kind": "user", "address": {"street": "123 Easy St", "city": "Anytown"}, "name": "Sandy", "privateAttributes": ["name", "/address/city"]})
126
+ ld_context = context_converter.to_ld_context(context)
127
+
128
+ assert ld_context.valid is True
129
+ assert ld_context.key == 'user-key'
130
+ assert ld_context.kind == 'user'
131
+ assert ld_context.private_attributes == ["name", "/address/city"]
132
+
133
+
134
+ def test_can_create_multi_kind_context(context_converter: EvaluationContextConverter):
135
+ attributes = {
136
+ 'kind': 'multi',
137
+ 'user': {'key': 'user-key', 'name': 'User name'},
138
+ 'org': {'key': 'org-key', 'name': 'Org name'},
139
+ }
140
+ context = EvaluationContext(None, attributes)
141
+ ld_context = context_converter.to_ld_context(context)
142
+
143
+ assert ld_context.valid is True
144
+ assert ld_context.multiple is True
145
+
146
+ user_context = ld_context.get_individual_context('user')
147
+ assert user_context is not None
148
+ assert user_context.key == 'user-key'
149
+ assert user_context.kind == 'user'
150
+ assert user_context.name == 'User name'
151
+
152
+ org_context = ld_context.get_individual_context('org')
153
+ assert org_context is not None
154
+ assert org_context.key == 'org-key'
155
+ assert org_context.kind == 'org'
156
+ assert org_context.name == 'Org name'
157
+
158
+
159
+ def test_multi_context_discards_invalid_single_kind(context_converter: EvaluationContextConverter):
160
+ attributes = {
161
+ 'kind': 'multi',
162
+ 'user': False,
163
+ 'org': {'key': 'org-key', 'name': 'Org name'},
164
+ }
165
+ context = EvaluationContext(None, attributes)
166
+ ld_context = context_converter.to_ld_context(context)
167
+
168
+ assert ld_context.valid is True
169
+ assert ld_context.multiple is False
170
+ assert ld_context.key == 'org-key'
171
+ assert ld_context.kind == 'org'
172
+ assert ld_context.name == 'Org name'
173
+
174
+
175
+ def test_handles_invalid_nested_contexts(context_converter: EvaluationContextConverter):
176
+ attributes = {
177
+ 'kind': 'multi',
178
+ 'user': 'invalid format',
179
+ 'org': False
180
+ }
181
+ context = EvaluationContext(None, attributes)
182
+ ld_context = context_converter.to_ld_context(context)
183
+
184
+ assert ld_context.valid is False
185
+ assert ld_context.multiple is False
@@ -0,0 +1,48 @@
1
+ from typing import Optional, Union
2
+
3
+ import pytest
4
+ from ldclient.evaluation import EvaluationDetail
5
+ from openfeature.exception import ErrorCode
6
+ from openfeature.flag_evaluation import Reason
7
+
8
+ from ld_openfeature.impl.details_converter import ResolutionDetailsConverter
9
+
10
+
11
+ @pytest.fixture
12
+ def details_converter() -> ResolutionDetailsConverter:
13
+ return ResolutionDetailsConverter()
14
+
15
+
16
+ @pytest.mark.parametrize(
17
+ 'detail_kind,reason',
18
+ [
19
+ pytest.param('OFF', Reason.DISABLED),
20
+ pytest.param('TARGET_MATCH', Reason.TARGETING_MATCH),
21
+ pytest.param('ERROR', Reason.ERROR),
22
+ pytest.param('FALLTHROUGH', 'FALLTHROUGH'),
23
+ pytest.param('RULE_MATCH', 'RULE_MATCH'),
24
+ pytest.param('PREREQUISITE_FAILED', 'PREREQUISITE_FAILED'),
25
+ ],
26
+ )
27
+ def test_ld_to_openfeature_kind_mappings(detail_kind: str, reason: Union[str, Reason], details_converter: ResolutionDetailsConverter):
28
+ detail = EvaluationDetail(True, None, {'kind': detail_kind})
29
+ resolution_details = details_converter.to_resolution_details(detail)
30
+ assert resolution_details.reason == reason
31
+
32
+
33
+ @pytest.mark.parametrize(
34
+ 'error_kind,error_code',
35
+ [
36
+ pytest.param(None, ErrorCode.GENERAL),
37
+ pytest.param('CLIENT_NOT_READY', ErrorCode.PROVIDER_NOT_READY),
38
+ pytest.param('FLAG_NOT_FOUND', ErrorCode.FLAG_NOT_FOUND),
39
+ pytest.param('MALFORMED_FLAG', ErrorCode.PARSE_ERROR),
40
+ pytest.param('USER_NOT_SPECIFIED', ErrorCode.TARGETING_KEY_MISSING),
41
+ pytest.param('EXCEPTION_ERROR', ErrorCode.GENERAL),
42
+ ],
43
+ )
44
+ def test_ld_to_openfeature_error_kind_mappings(error_kind: Optional[str], error_code: ErrorCode, details_converter: ResolutionDetailsConverter):
45
+ detail = EvaluationDetail(True, None, {'kind': 'ERROR', 'errorKind': error_kind})
46
+ resolution_details = details_converter.to_resolution_details(detail)
47
+ assert resolution_details.reason == Reason.ERROR
48
+ assert resolution_details.error_code == error_code
@@ -0,0 +1,130 @@
1
+ from typing import List, Union
2
+ from unittest.mock import patch
3
+
4
+ import pytest
5
+ from ldclient import Config, LDClient
6
+ from ldclient.evaluation import EvaluationDetail
7
+ from ldclient.integrations.test_data import TestData
8
+ from openfeature.evaluation_context import EvaluationContext
9
+ from openfeature.exception import ErrorCode
10
+ from openfeature.flag_evaluation import Reason
11
+
12
+ from ld_openfeature import LaunchDarklyProvider
13
+
14
+
15
+ @pytest.fixture
16
+ def test_data_source() -> TestData:
17
+ td = TestData.data_source()
18
+ td.update(td.flag("fallthrough-boolean").variation_for_all(True))
19
+ return td
20
+
21
+
22
+ @pytest.fixture
23
+ def evaluation_context() -> EvaluationContext:
24
+ return EvaluationContext('user-key')
25
+
26
+
27
+ @pytest.fixture
28
+ def config(test_data_source: TestData) -> Config:
29
+ return Config("example-key", update_processor_class=test_data_source, send_events=False)
30
+
31
+
32
+ @pytest.fixture
33
+ def provider(config) -> LaunchDarklyProvider:
34
+ return LaunchDarklyProvider(config)
35
+
36
+
37
+ def test_metadata_name_is_correct(provider: LaunchDarklyProvider):
38
+ assert provider.get_metadata().name == "launchdarkly-openfeature-server"
39
+
40
+
41
+ def test_not_providing_context_returns_error(provider: LaunchDarklyProvider):
42
+ resolution_details = provider.resolve_boolean_details("flag-key", True, None)
43
+
44
+ assert resolution_details.value is True
45
+ assert resolution_details.reason == Reason.ERROR
46
+ assert resolution_details.variant is None
47
+ assert resolution_details.error_code == ErrorCode.TARGETING_KEY_MISSING
48
+
49
+
50
+ def test_evaluation_results_are_converted_to_details(provider: LaunchDarklyProvider, evaluation_context: EvaluationContext):
51
+ resolution_details = provider.resolve_boolean_details("fallthrough-boolean", True, evaluation_context)
52
+
53
+ assert resolution_details.value is True
54
+ assert resolution_details.reason == 'FALLTHROUGH'
55
+ assert resolution_details.variant == '0'
56
+ assert resolution_details.error_code is None
57
+
58
+
59
+ def test_evaluation_error_results_are_converted_correctly(provider: LaunchDarklyProvider, evaluation_context: EvaluationContext):
60
+ detail = EvaluationDetail(True, None, {'kind': 'ERROR', 'errorKind': 'CLIENT_NOT_READY'})
61
+ with patch.object(LDClient, 'variation_detail', lambda self, _key, _context, _default: detail):
62
+ resolution_details = provider.resolve_boolean_details("flag-key", True, evaluation_context)
63
+
64
+ assert resolution_details.value is True
65
+ assert resolution_details.reason == Reason.ERROR
66
+ assert resolution_details.variant is None
67
+ assert resolution_details.error_code == ErrorCode.PROVIDER_NOT_READY
68
+
69
+
70
+ def test_invalid_types_generate_type_mismatch_results(provider: LaunchDarklyProvider, evaluation_context: EvaluationContext):
71
+ resolution_details = provider.resolve_string_details("fallthrough-boolean", "default-value", evaluation_context)
72
+
73
+ assert resolution_details.value == "default-value"
74
+ assert resolution_details.reason == Reason.ERROR
75
+ assert resolution_details.variant is None
76
+ assert resolution_details.error_code == ErrorCode.TYPE_MISMATCH
77
+
78
+
79
+ @pytest.mark.parametrize(
80
+ "default_value,return_value,expected_value,method_name",
81
+ [
82
+ pytest.param(True, False, False, 'resolve_boolean_details'),
83
+ pytest.param(False, True, True, 'resolve_boolean_details'),
84
+ pytest.param(False, 1, False, 'resolve_boolean_details'),
85
+ pytest.param(False, "True", False, 'resolve_boolean_details'),
86
+ pytest.param(True, [], True, 'resolve_boolean_details'),
87
+
88
+ pytest.param('default-string', 'return-string', 'return-string', 'resolve_string_details'),
89
+ pytest.param('default-string', 1, 'default-string', 'resolve_string_details'),
90
+ pytest.param('default-string', True, 'default-string', 'resolve_string_details'),
91
+
92
+ pytest.param(1, 2, 2, 'resolve_integer_details'),
93
+ pytest.param(1, True, 1, 'resolve_integer_details'),
94
+ pytest.param(1, False, 1, 'resolve_integer_details'),
95
+ pytest.param(1, "", 1, 'resolve_integer_details'),
96
+
97
+ pytest.param(1.0, 2.0, 2.0, 'resolve_float_details'),
98
+ pytest.param(1.0, 2, 2.0, 'resolve_float_details'),
99
+ pytest.param(1.0, True, 1.0, 'resolve_float_details'),
100
+ pytest.param(1.0, 'return-string', 1.0, 'resolve_float_details'),
101
+
102
+ pytest.param(['default-value'], ['return-string'], ['return-string'], 'resolve_object_details'),
103
+ pytest.param(['default-value'], True, ['default-value'], 'resolve_object_details'),
104
+ pytest.param(['default-value'], 1, ['default-value'], 'resolve_object_details'),
105
+ pytest.param(['default-value'], 'return-string', ['default-value'], 'resolve_object_details'),
106
+ ],
107
+ )
108
+ def test_check_method_and_result_match_type(
109
+ # start of parameterized values
110
+ default_value: Union[bool, str, int, float, List],
111
+ return_value: Union[bool, str, int, float, List],
112
+ expected_value: Union[bool, str, int, float, List],
113
+ method_name: str,
114
+ # end of parameterized values
115
+ test_data_source: TestData,
116
+ provider: LaunchDarklyProvider,
117
+ evaluation_context: EvaluationContext):
118
+ test_data_source.update(test_data_source.flag("check-method-flag").variations(return_value).variation_for_all(0))
119
+
120
+ method = getattr(provider, method_name)
121
+ resolution_details = method("check-method-flag", default_value, evaluation_context)
122
+
123
+ assert resolution_details.value == expected_value
124
+
125
+
126
+ def test_logger_changes_should_cascade_to_evaluation_converter(provider: LaunchDarklyProvider, caplog):
127
+ _ = provider.resolve_boolean_details("fallthrough-boolean", False, EvaluationContext('user-key', {'kind': False}))
128
+
129
+ assert len(caplog.records) == 1
130
+ assert caplog.records[0].message == "'kind' was set to a non-string value; defaulting to user"