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.
- launchdarkly_openfeature_server-0.1.0/LICENSE +13 -0
- launchdarkly_openfeature_server-0.1.0/PKG-INFO +189 -0
- launchdarkly_openfeature_server-0.1.0/README.md +162 -0
- launchdarkly_openfeature_server-0.1.0/ld_openfeature/__init__.py +5 -0
- launchdarkly_openfeature_server-0.1.0/ld_openfeature/impl/context_converter.py +113 -0
- launchdarkly_openfeature_server-0.1.0/ld_openfeature/impl/details_converter.py +68 -0
- launchdarkly_openfeature_server-0.1.0/ld_openfeature/provider.py +111 -0
- launchdarkly_openfeature_server-0.1.0/ld_openfeature/py.typed +1 -0
- launchdarkly_openfeature_server-0.1.0/pyproject.toml +74 -0
- launchdarkly_openfeature_server-0.1.0/tests/__init__.py +0 -0
- launchdarkly_openfeature_server-0.1.0/tests/impl/test_context_converter.py +185 -0
- launchdarkly_openfeature_server-0.1.0/tests/impl/test_details_converter.py +48 -0
- launchdarkly_openfeature_server-0.1.0/tests/test_provider.py +130 -0
|
@@ -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
|
+
[](https://github.com/launchdarkly/openfeature-python-server/actions/workflows/ci.yml)
|
|
30
|
+
[](https://packagist.org/packages/launchdarkly/openfeature-server)
|
|
31
|
+
[](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
|
+
[](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
|
+
[](https://github.com/launchdarkly/openfeature-python-server/actions/workflows/ci.yml)
|
|
4
|
+
[](https://packagist.org/packages/launchdarkly/openfeature-server)
|
|
5
|
+
[](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
|
+
[](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,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 @@
|
|
|
1
|
+
|
|
@@ -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"
|
|
File without changes
|
|
@@ -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"
|