devcycle-python-server-sdk 3.3.6__py3-none-any.whl → 3.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- 3.3.6
1
+ 3.5.0
@@ -1,5 +1,6 @@
1
1
  # Simplify imports for the SDK entry point objects
2
2
 
3
3
  from devcycle_python_sdk.options import DevCycleCloudOptions, DevCycleLocalOptions
4
+ from devcycle_python_sdk.devcycle_client import AbstractDevCycleClient
4
5
  from devcycle_python_sdk.cloud_client import DevCycleCloudClient
5
6
  from devcycle_python_sdk.local_client import DevCycleLocalClient
@@ -3,7 +3,7 @@ import platform
3
3
 
4
4
  from typing import Any, Dict
5
5
 
6
- from devcycle_python_sdk import DevCycleCloudOptions
6
+ from devcycle_python_sdk import DevCycleCloudOptions, AbstractDevCycleClient
7
7
  from devcycle_python_sdk.api.bucketing_client import BucketingAPIClient
8
8
  from devcycle_python_sdk.exceptions import (
9
9
  NotFoundError,
@@ -14,11 +14,18 @@ from devcycle_python_sdk.models.event import DevCycleEvent
14
14
  from devcycle_python_sdk.models.variable import Variable
15
15
  from devcycle_python_sdk.models.feature import Feature
16
16
  from devcycle_python_sdk.util.version import sdk_version
17
+ from devcycle_python_sdk.open_feature_provider.provider import DevCycleProvider
18
+
19
+ from openfeature.provider import AbstractProvider
17
20
 
18
21
  logger = logging.getLogger(__name__)
19
22
 
20
23
 
21
- class DevCycleCloudClient:
24
+ class DevCycleCloudClient(AbstractDevCycleClient):
25
+ """
26
+ The DevCycle Python SDK that utilizes the DevCycle Bucketing API for feature and variable evaluation
27
+ """
28
+
22
29
  options: DevCycleCloudOptions
23
30
  platform: str
24
31
  platform_version: str
@@ -37,6 +44,13 @@ class DevCycleCloudClient:
37
44
  self.sdk_version = sdk_version()
38
45
  self.sdk_type = "server"
39
46
  self.bucketing_api = BucketingAPIClient(sdk_key, self.options)
47
+ self._openfeature_provider = DevCycleProvider(self)
48
+
49
+ def get_sdk_platform(self) -> str:
50
+ return "Cloud"
51
+
52
+ def get_openfeature_provider(self) -> AbstractProvider:
53
+ return self._openfeature_provider
40
54
 
41
55
  def _add_platform_data_to_user(self, user: DevCycleUser) -> DevCycleUser:
42
56
  user.platform = self.platform
@@ -45,6 +59,9 @@ class DevCycleCloudClient:
45
59
  user.sdkType = self.sdk_type
46
60
  return user
47
61
 
62
+ def is_initialized(self) -> bool:
63
+ return True
64
+
48
65
  def variable_value(self, user: DevCycleUser, key: str, default_value: Any) -> Any:
49
66
  """
50
67
  Evaluates a variable for a user and returns the value. If the user is not bucketed into the variable, the default value will be returned
@@ -0,0 +1,40 @@
1
+ import typing
2
+ from abc import abstractmethod
3
+
4
+ from devcycle_python_sdk.models.user import DevCycleUser
5
+ from devcycle_python_sdk.models.variable import Variable
6
+
7
+ from openfeature.provider import AbstractProvider
8
+
9
+
10
+ class AbstractDevCycleClient:
11
+ """
12
+ A common interface for all DevCycle Clients
13
+ """
14
+
15
+ @abstractmethod
16
+ def is_initialized(self) -> bool:
17
+ pass
18
+
19
+ @abstractmethod
20
+ def variable(
21
+ self, user: DevCycleUser, key: str, default_value: typing.Any
22
+ ) -> Variable:
23
+ pass
24
+
25
+ @abstractmethod
26
+ def variable_value(
27
+ self, user: DevCycleUser, key: str, default_value: typing.Any
28
+ ) -> typing.Any:
29
+ pass
30
+
31
+ @abstractmethod
32
+ def get_openfeature_provider(self) -> AbstractProvider:
33
+ """
34
+ Returns the OpenFeature provider for this client
35
+ """
36
+ pass
37
+
38
+ @abstractmethod
39
+ def get_sdk_platform(self) -> str:
40
+ pass
@@ -3,7 +3,7 @@ import logging
3
3
  from numbers import Real
4
4
  from typing import Any, Dict, Union
5
5
 
6
- from devcycle_python_sdk import DevCycleLocalOptions
6
+ from devcycle_python_sdk import DevCycleLocalOptions, AbstractDevCycleClient
7
7
  from devcycle_python_sdk.api.local_bucketing import LocalBucketing
8
8
  from devcycle_python_sdk.exceptions import VariableTypeMismatchError
9
9
  from devcycle_python_sdk.managers.config_manager import EnvironmentConfigManager
@@ -14,11 +14,17 @@ from devcycle_python_sdk.models.feature import Feature
14
14
  from devcycle_python_sdk.models.platform_data import default_platform_data
15
15
  from devcycle_python_sdk.models.user import DevCycleUser
16
16
  from devcycle_python_sdk.models.variable import Variable
17
+ from devcycle_python_sdk.open_feature_provider.provider import DevCycleProvider
18
+ from openfeature.provider import AbstractProvider
17
19
 
18
20
  logger = logging.getLogger(__name__)
19
21
 
20
22
 
21
- class DevCycleLocalClient:
23
+ class DevCycleLocalClient(AbstractDevCycleClient):
24
+ """
25
+ The DevCycle Python SDK that utilizes the local bucketing library for feature and variable evaluation
26
+ """
27
+
22
28
  def __init__(self, sdk_key: str, options: DevCycleLocalOptions):
23
29
  _validate_sdk_key(sdk_key)
24
30
  self._sdk_key = sdk_key
@@ -42,6 +48,14 @@ class DevCycleLocalClient:
42
48
  sdk_key, self.options, self.local_bucketing
43
49
  )
44
50
 
51
+ self._openfeature_provider = DevCycleProvider(self)
52
+
53
+ def get_sdk_platform(self) -> str:
54
+ return "Local"
55
+
56
+ def get_openfeature_provider(self) -> AbstractProvider:
57
+ return self._openfeature_provider
58
+
45
59
  def is_initialized(self) -> bool:
46
60
  return self.config_manager.is_initialized()
47
61
 
@@ -55,6 +55,7 @@ class OptInSettings:
55
55
  class ProjectSettings:
56
56
  edge_db: Optional[EdgeDBSettings]
57
57
  opt_in: Optional[OptInSettings]
58
+ disable_passthrough_rollouts: Optional[bool]
58
59
 
59
60
  @classmethod
60
61
  def from_json(cls, data: dict) -> "ProjectSettings":
@@ -63,6 +64,7 @@ class ProjectSettings:
63
64
  if "edgeDB" in data
64
65
  else None,
65
66
  opt_in=OptInSettings.from_json(data["optIn"]) if "optIn" in data else None,
67
+ disable_passthrough_rollouts=data.get("disablePassthroughRollouts", False),
66
68
  )
67
69
 
68
70
 
@@ -1,7 +1,9 @@
1
1
  # ruff: noqa: N815
2
2
  from dataclasses import dataclass, field
3
3
  from datetime import datetime, timezone
4
- from typing import Dict, Optional
4
+ from typing import Dict, Optional, Any
5
+ from openfeature.evaluation_context import EvaluationContext
6
+ from openfeature.exception import TargetingKeyMissingError, InvalidContextError
5
7
 
6
8
 
7
9
  @dataclass(order=False)
@@ -13,9 +15,9 @@ class DevCycleUser:
13
15
  country: Optional[str] = None
14
16
  appVersion: Optional[str] = None
15
17
  appBuild: Optional[str] = None
16
- customData: Optional[Dict[str, str]] = None
18
+ customData: Optional[Dict[str, Any]] = None
17
19
  createdDate: datetime = field(default_factory=lambda: datetime.utcnow())
18
- privateCustomData: Optional[Dict[str, str]] = None
20
+ privateCustomData: Optional[Dict[str, Any]] = None
19
21
  lastSeenDate: Optional[datetime] = None
20
22
  platform: Optional[str] = None
21
23
  platformVersion: Optional[str] = None
@@ -74,3 +76,84 @@ class DevCycleUser:
74
76
  sdkType=data.get("sdkType"),
75
77
  sdkVersion=data.get("sdkVersion"),
76
78
  )
79
+
80
+ @staticmethod
81
+ def _set_custom_value(custom_data: Dict[str, Any], key: str, value: Optional[Any]):
82
+ """
83
+ Sets a custom value in the custom data dictionary. Custom data properties can
84
+ only be strings, numbers, or booleans. Nested dictionaries and lists are
85
+ not permitted.
86
+
87
+ Invalid values will generate an error
88
+ """
89
+ if key and (value is None or isinstance(value, (str, int, float, bool))):
90
+ custom_data[key] = value
91
+ else:
92
+ raise InvalidContextError(
93
+ "Custom property values must be strings, numbers, booleans or None"
94
+ )
95
+
96
+ @staticmethod
97
+ def create_user_from_context(
98
+ context: Optional[EvaluationContext],
99
+ ) -> "DevCycleUser":
100
+ """
101
+ Builds a DevCycleUser instance from the evaluation context. Will raise a TargetingKeyMissingError if
102
+ the context does not contain a valid targeting key or user_id attribute
103
+
104
+ :param context: The evaluation context to build the user from
105
+ :return: A DevCycleUser instance
106
+ """
107
+ user_id = None
108
+
109
+ if context:
110
+ if context.targeting_key:
111
+ user_id = context.targeting_key
112
+ elif context.attributes and "user_id" in context.attributes.keys():
113
+ user_id = context.attributes["user_id"]
114
+
115
+ if not user_id or not isinstance(user_id, str):
116
+ raise TargetingKeyMissingError(
117
+ "DevCycle: Evaluation context does not contain a valid targeting key or user_id attribute"
118
+ )
119
+
120
+ user = DevCycleUser(user_id=user_id)
121
+ custom_data: Dict[str, Any] = {}
122
+ private_custom_data: Dict[str, Any] = {}
123
+ if context and context.attributes:
124
+ for key, value in context.attributes.items():
125
+ if key == "user_id":
126
+ continue
127
+
128
+ if value:
129
+ if key == "email" and isinstance(value, str):
130
+ user.email = value
131
+ elif key == "name" and isinstance(value, str):
132
+ user.name = value
133
+ elif key == "language" and isinstance(value, str):
134
+ user.language = value
135
+ elif key == "country" and isinstance(value, str):
136
+ user.country = value
137
+ elif key == "appVersion" and isinstance(value, str):
138
+ user.appVersion = value
139
+ elif key == "appBuild" and isinstance(value, str):
140
+ user.appBuild = value
141
+ elif key == "deviceModel" and isinstance(value, str):
142
+ user.deviceModel = value
143
+ elif key == "customData" and isinstance(value, dict):
144
+ for k, v in value.items():
145
+ DevCycleUser._set_custom_value(custom_data, k, v)
146
+ elif key == "privateCustomData" and isinstance(value, dict):
147
+ for k, v in value.items():
148
+ DevCycleUser._set_custom_value(private_custom_data, k, v)
149
+ else:
150
+ # unrecognized keys are just added to public custom data
151
+ DevCycleUser._set_custom_value(custom_data, key, value)
152
+
153
+ if custom_data:
154
+ user.customData = custom_data
155
+
156
+ if private_custom_data:
157
+ user.privateCustomData = private_custom_data
158
+
159
+ return user
File without changes
@@ -0,0 +1,123 @@
1
+ import logging
2
+
3
+ from typing import Any, Optional, Union, List
4
+
5
+ from devcycle_python_sdk import AbstractDevCycleClient
6
+ from devcycle_python_sdk.models.user import DevCycleUser
7
+
8
+ from openfeature.provider import AbstractProvider
9
+ from openfeature.evaluation_context import EvaluationContext
10
+ from openfeature.flag_evaluation import FlagResolutionDetails, Reason
11
+ from openfeature.exception import ErrorCode, InvalidContextError, TypeMismatchError
12
+ from openfeature.hook import Hook
13
+ from openfeature.provider.metadata import Metadata
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class DevCycleProvider(AbstractProvider):
19
+ """
20
+ Openfeature provider wrapper for the DevCycle SDK.
21
+
22
+ Can be initialized with either a DevCycleLocalClient or DevCycleCloudClient instance.
23
+ """
24
+
25
+ def __init__(self, devcycle_client: AbstractDevCycleClient):
26
+ self.client = devcycle_client
27
+ self.meta_data = Metadata(name=f"DevCycle {self.client.get_sdk_platform()}")
28
+
29
+ def get_metadata(self) -> Metadata:
30
+ return self.meta_data
31
+
32
+ def get_provider_hooks(self) -> List[Hook]:
33
+ return []
34
+
35
+ def _resolve(
36
+ self,
37
+ flag_key: str,
38
+ default_value: Any,
39
+ evaluation_context: Optional[EvaluationContext] = None,
40
+ ) -> FlagResolutionDetails[Any]:
41
+ if self.client.is_initialized():
42
+ try:
43
+ user: DevCycleUser = DevCycleUser.create_user_from_context(
44
+ evaluation_context
45
+ )
46
+
47
+ variable = self.client.variable(
48
+ key=flag_key, user=user, default_value=default_value
49
+ )
50
+
51
+ if variable is None:
52
+ # this technically should never happen
53
+ # as the DevCycle client should at least return a default Variable instance
54
+ return FlagResolutionDetails(
55
+ value=default_value,
56
+ reason=Reason.DEFAULT,
57
+ )
58
+ else:
59
+ return FlagResolutionDetails(
60
+ value=variable.value,
61
+ reason=Reason.DEFAULT
62
+ if variable.isDefaulted
63
+ else Reason.TARGETING_MATCH,
64
+ )
65
+ except ValueError as e:
66
+ # occurs if the key or default value is None
67
+ raise InvalidContextError(str(e))
68
+ else:
69
+ return FlagResolutionDetails(
70
+ value=default_value,
71
+ reason=Reason.ERROR,
72
+ error_code=ErrorCode.PROVIDER_NOT_READY,
73
+ )
74
+
75
+ def resolve_boolean_details(
76
+ self,
77
+ flag_key: str,
78
+ default_value: bool,
79
+ evaluation_context: Optional[EvaluationContext] = None,
80
+ ) -> FlagResolutionDetails[bool]:
81
+ return self._resolve(flag_key, default_value, evaluation_context)
82
+
83
+ def resolve_string_details(
84
+ self,
85
+ flag_key: str,
86
+ default_value: str,
87
+ evaluation_context: Optional[EvaluationContext] = None,
88
+ ) -> FlagResolutionDetails[str]:
89
+ return self._resolve(flag_key, default_value, evaluation_context)
90
+
91
+ def resolve_integer_details(
92
+ self,
93
+ flag_key: str,
94
+ default_value: int,
95
+ evaluation_context: Optional[EvaluationContext] = None,
96
+ ) -> FlagResolutionDetails[int]:
97
+ return self._resolve(flag_key, default_value, evaluation_context)
98
+
99
+ def resolve_float_details(
100
+ self,
101
+ flag_key: str,
102
+ default_value: float,
103
+ evaluation_context: Optional[EvaluationContext] = None,
104
+ ) -> FlagResolutionDetails[float]:
105
+ return self._resolve(flag_key, default_value, evaluation_context)
106
+
107
+ def resolve_object_details(
108
+ self,
109
+ flag_key: str,
110
+ default_value: Union[dict, list],
111
+ evaluation_context: Optional[EvaluationContext] = None,
112
+ ) -> FlagResolutionDetails[Union[dict, list]]:
113
+ if not isinstance(default_value, dict):
114
+ raise TypeMismatchError("Default value must be a flat dictionary")
115
+
116
+ if default_value:
117
+ for k, v in default_value.items():
118
+ if not isinstance(v, (str, int, float, bool)) or v is None:
119
+ raise TypeMismatchError(
120
+ "Default value must be a flat dictionary containing only strings, numbers, booleans or None values"
121
+ )
122
+
123
+ return self._resolve(flag_key, default_value, evaluation_context)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: devcycle-python-server-sdk
3
- Version: 3.3.6
3
+ Version: 3.5.0
4
4
  Summary: DevCycle Python SDK
5
5
  Home-page: https://github.com/devcycleHQ/python-server-sdk
6
6
  Author: UNKNOWN
@@ -13,6 +13,7 @@ Requires-Dist: urllib3 >=1.15.1
13
13
  Requires-Dist: requests ~=2.31.0
14
14
  Requires-Dist: wasmtime ==9.0.0
15
15
  Requires-Dist: protobuf >=4.23.3
16
+ Requires-Dist: openfeature-sdk >=0.7.0
16
17
 
17
18
  The DevCycle Python SDK used for feature management.
18
19
 
@@ -1,9 +1,10 @@
1
- devcycle_python_sdk/VERSION.txt,sha256=NnbsD-Jmzgwy0kLCa3npOIpV_H1Uvl9iyKC5uFowDYw,6
2
- devcycle_python_sdk/__init__.py,sha256=vOoglQ8L7RsHTp-GSa8g7k2AAPrR_obhiDSC09LpG8w,265
3
- devcycle_python_sdk/bucketing-lib.release.wasm,sha256=oNVNiF5h5YO4kejqbdogK44DVgWgwn5UJZ7KUsR9aZA,200891
4
- devcycle_python_sdk/cloud_client.py,sha256=wkDNwz8GFfnJybD1e96Lr41SY_2saQUTMD5gByFIbIg,6766
1
+ devcycle_python_sdk/VERSION.txt,sha256=dhIMHyxDEcIVTCKmtY4A5BHg6-dq-uDa9gjZGWxh8RQ,6
2
+ devcycle_python_sdk/__init__.py,sha256=30TcYb9PbvVi97gwk16omCGG1UoUI9lmmU7xpvxNMvc,336
3
+ devcycle_python_sdk/bucketing-lib.release.wasm,sha256=yrNDrAxZS1h9VlTzsdOrNLP4ngnM04PmKxcWop1yrt4,202623
4
+ devcycle_python_sdk/cloud_client.py,sha256=UMfXvXwO4v3pZW9ZA52ivxPH1Lp4Goy5gpV_s0qCpxQ,7352
5
+ devcycle_python_sdk/devcycle_client.py,sha256=W5MeOslkXjTqZtoSy_Bbbug8gVoicjxshpKjDteEr6E,917
5
6
  devcycle_python_sdk/exceptions.py,sha256=Et7fS8pEFbHkFY86T2pJoEUstwjh4_rzhJxVH8fzAns,1053
6
- devcycle_python_sdk/local_client.py,sha256=h5NrP4-x26cz-yla7OUy4SL7VJgplUFH8Fj0jTqz2Os,8845
7
+ devcycle_python_sdk/local_client.py,sha256=5PFEUmBs-QsgaiXWBQfhhjcN2TbB64JH3iVwpJteG7o,9373
7
8
  devcycle_python_sdk/options.py,sha256=Ejr6HbdyqzZajFVkEqWLk5XLfdtqCIkvsyWaRt_lgwU,4575
8
9
  devcycle_python_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
10
  devcycle_python_sdk/api/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
@@ -16,13 +17,15 @@ devcycle_python_sdk/managers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NM
16
17
  devcycle_python_sdk/managers/config_manager.py,sha256=_-1kWwyyAcwVF_DibXjnehXi3Qj6HR8orYfgNfgWJOc,3248
17
18
  devcycle_python_sdk/managers/event_queue_manager.py,sha256=06TcdlRoB89C7vGH1UTnnoDFsSnT71tPwf0gn1NK-W0,8071
18
19
  devcycle_python_sdk/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
- devcycle_python_sdk/models/bucketed_config.py,sha256=rSKpxMTF-IbUVQ9gAWrWnb9Q9YF94ATqEFWNWuoQntk,3814
20
+ devcycle_python_sdk/models/bucketed_config.py,sha256=76bdF7pgUdIAn2y8YPs3AO7Qa2eyJQ_jf4QhG3IERaE,3951
20
21
  devcycle_python_sdk/models/error_response.py,sha256=r_dRUDAbral9UGaMYcnBsZOr9Hmkyi1SQbhGmZilgpA,442
21
22
  devcycle_python_sdk/models/event.py,sha256=-6bhKp72mAgaoSxvhS__G4vvfQ9Psj83fc0Igelzw3o,3713
22
23
  devcycle_python_sdk/models/feature.py,sha256=m3wxxIjC5jxhKpPLI4RcsBaNLuy-zsMqoAgc8ySPe0I,852
23
24
  devcycle_python_sdk/models/platform_data.py,sha256=kIFevH5trsxI32qp7BW6glUCpipgpaVBKGqJWzQd5Xo,783
24
- devcycle_python_sdk/models/user.py,sha256=BGMtVM6shDX7rUfGfBbI55IUEsQ_4qrnmtwTqOcB-8A,2616
25
+ devcycle_python_sdk/models/user.py,sha256=57d_n8Yy8g-zsGzVF0hmSf4McPtFPJJ0LLY_ql75FWk,6266
25
26
  devcycle_python_sdk/models/variable.py,sha256=qp00SyCNyfYiMlTwnDSDJ4ZetRF35JHTbYZQOBxFIqY,1711
27
+ devcycle_python_sdk/open_feature_provider/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
28
+ devcycle_python_sdk/open_feature_provider/provider.py,sha256=FjNxmyKrnUO4ma4c-Z39qWigLVC_P-L_Nly0mycA1Vc,4475
26
29
  devcycle_python_sdk/protobuf/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
27
30
  devcycle_python_sdk/protobuf/utils.py,sha256=zi-kVx6yvgS2c04cUobLd2IDc_ZqKkWduay9YcpjoGc,4684
28
31
  devcycle_python_sdk/protobuf/variableForUserParams_pb2.py,sha256=Zf4-8n67UnulC0Sz90aqLYsXCFEgywOqfiSHuXZomLk,4244
@@ -31,15 +34,16 @@ devcycle_python_sdk/util/strings.py,sha256=gH0Xm3gIXNqn6hczKPk10Jn5_9knuD7OB5MrB
31
34
  devcycle_python_sdk/util/version.py,sha256=Cz7Us_EuXP-T8GI47g2WHYdBJj_zKo-5y545IDHJAjI,253
32
35
  example/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
33
36
  example/cloud_client_example.py,sha256=YD8ZsTv3dPYfbFUKraRpRT0VsJGrPwjm8ZCuA5H54yI,2463
34
- example/local_bucketing_client_example.py,sha256=zSJsllQ5aSvUTj-VFvt0w7pVTMRW6z-gTj9R7ZNPlyE,2797
37
+ example/local_bucketing_client_example.py,sha256=TaIfy3VnW9fXii9NPYs5UOuP4fLKruXMjnZApveqzl4,2796
38
+ example/openfeature_example.py,sha256=kO6F-Xk20l6CPiMfIxSRreshEGmHMFHRg3zbeLXa3AI,2410
35
39
  test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
36
40
  test/test_cloud_client.py,sha256=zjk302m7qKMCQryolkc1WKLFmbGWUxA0VtHlZk0rY-0,11442
37
- test/test_local_client.py,sha256=mEBhq0gnHzjE-i_w4hMrmfhoHDJvCXGt3G8lx0EA9OE,12671
41
+ test/test_local_client.py,sha256=Ds6VIfLcaefboyvGrhQTGsdJj_DGB4IVnR2_16xDpSY,13022
38
42
  test/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
39
43
  test/api/test_bucketing_client.py,sha256=M2mjACowLvbsIjXdP-w8-fwY02xqv1X1r_h9v-pXa7A,7739
40
44
  test/api/test_config_client.py,sha256=oZMBlXGw4UHb5-rSNF_ARTrAjvbvkbFgb8GAK4ExR14,4413
41
45
  test/api/test_event_client.py,sha256=v4Xoum-PdywfczrfMV1llM5vrIJoqHybzxPLVokh09g,3310
42
- test/api/test_local_bucketing.py,sha256=E7alurvIScT0nilcuY2xGLCXzwxHrNWPBGdWxG6bR5A,15156
46
+ test/api/test_local_bucketing.py,sha256=lPL3ThZcGG-8-67jFxRvGrwL8NB6-sM5qGJb0p2O__Q,15254
43
47
  test/fixture/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
44
48
  test/fixture/data.py,sha256=6ReLxWPMhCDbJ5Ex24_iR0PJNjKzPFV1I9j_v0FVbBU,1462
45
49
  test/managers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -47,12 +51,16 @@ test/managers/test_config_manager.py,sha256=_MtvyxK06xpBzzEsN1zsLdYrk3DsPsbCmSj7
47
51
  test/managers/test_event_queue_manager.py,sha256=KquHBuYiDdoVStm5Ga9mIB-wvmJnChiFRBNC8sMKaxQ,11053
48
52
  test/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
49
53
  test/models/test_bucketed_config.py,sha256=8wCEyxaHxY21nl7ZbiFzp-IzZQBZIPIVPPzeREjumQk,858
54
+ test/models/test_user.py,sha256=ZNTBSKLDrqrsqZuaw5AHtc_FW4empSTaeNPZHZaFlI4,6736
55
+ test/openfeature_test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
56
+ test/openfeature_test/test_provider.py,sha256=UPxAD5e7NvpIhmqNFCrTY_yrKrMyYxBc2xuMAgdSGvw,7653
57
+ test/openfeature_test/test_provider_local_sdk.py,sha256=5NjfUDalW4F7Oe8n1740VO7l5OIBEOldnSryM98uO2w,4426
50
58
  test/util/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
51
59
  test/util/test_strings.py,sha256=T5H5U0af7_ld1yJC8IdDmay_Mx-iJQ5Fi5fbvKcAc7A,862
52
60
  test/util/test_utils.py,sha256=tVBJm_qnqg-4PssLpXIxqlk0JehuiAIPs4zpi2oNVnY,6671
53
- test/util/test_version.py,sha256=lx0HFefK_I5D35bmjEc7t5_6oYB4LBWnG-zxqCz1lV4,343
54
- devcycle_python_server_sdk-3.3.6.dist-info/LICENSE,sha256=tHQMqu-Jic2UpMGqb-vNFbGygysl8IG4l1clnnh0OFo,1071
55
- devcycle_python_server_sdk-3.3.6.dist-info/METADATA,sha256=r-YR9iEwUiLS5koBavumhlUhjc41IZ8X4di9iYUiwmw,471
56
- devcycle_python_server_sdk-3.3.6.dist-info/WHEEL,sha256=yQN5g4mg4AybRjkgi-9yy4iQEFibGQmlz78Pik5Or-A,92
57
- devcycle_python_server_sdk-3.3.6.dist-info/top_level.txt,sha256=34f8zDzihAHoH7siAFlmscyO_QLNWWq9HApFlwtYL4k,33
58
- devcycle_python_server_sdk-3.3.6.dist-info/RECORD,,
61
+ test/util/test_version.py,sha256=3Xg0_84m0bdHXz6pxd2Z85Oz9DoUC1T0Ol_WqEH_jac,335
62
+ devcycle_python_server_sdk-3.5.0.dist-info/LICENSE,sha256=tHQMqu-Jic2UpMGqb-vNFbGygysl8IG4l1clnnh0OFo,1071
63
+ devcycle_python_server_sdk-3.5.0.dist-info/METADATA,sha256=IAUJWGuZDWtOoCfJ6Tj6aKURxJODAtUuutO1g94hLho,510
64
+ devcycle_python_server_sdk-3.5.0.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
65
+ devcycle_python_server_sdk-3.5.0.dist-info/top_level.txt,sha256=34f8zDzihAHoH7siAFlmscyO_QLNWWq9HApFlwtYL4k,33
66
+ devcycle_python_server_sdk-3.5.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.41.2)
2
+ Generator: bdist_wheel (0.43.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -7,7 +7,7 @@ from devcycle_python_sdk import DevCycleLocalClient, DevCycleLocalOptions
7
7
  from devcycle_python_sdk.models.user import DevCycleUser
8
8
  from devcycle_python_sdk.models.event import DevCycleEvent, EventType
9
9
 
10
- VARIABLE_KEY = "test-boolean-variable"
10
+ VARIABLE_KEY = "python-example-tests"
11
11
 
12
12
  logger = logging.getLogger(__name__)
13
13
 
@@ -0,0 +1,77 @@
1
+ import logging
2
+ import time
3
+ import os
4
+
5
+ from devcycle_python_sdk import DevCycleLocalClient, DevCycleLocalOptions
6
+
7
+ from openfeature import api
8
+ from openfeature.evaluation_context import EvaluationContext
9
+
10
+ FLAG_KEY = "test-boolean-variable"
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def main():
16
+ """
17
+ Sample usage of the DevCycle OpenFeature Provider along with the Python Server SDK using Local Bucketing.
18
+ """
19
+ logging.basicConfig(level="INFO", format="%(levelname)s: %(message)s")
20
+
21
+ # create an instance of the DevCycle Client object
22
+ server_sdk_key = os.environ["DEVCYCLE_SERVER_SDK_KEY"]
23
+ devcycle_client = DevCycleLocalClient(server_sdk_key, DevCycleLocalOptions())
24
+
25
+ # Wait for DevCycle to initialize and load the configuration
26
+ for i in range(10):
27
+ if devcycle_client.is_initialized():
28
+ break
29
+ logger.info("Waiting for DevCycle to initialize...")
30
+ time.sleep(0.5)
31
+ else:
32
+ logger.error("DevCycle failed to initialize")
33
+ exit(1)
34
+
35
+ # set the provider for OpenFeature
36
+ api.set_provider(devcycle_client.get_openfeature_provider())
37
+
38
+ # get the OpenFeature client
39
+ open_feature_client = api.get_client()
40
+
41
+ # create the request context for the user
42
+ context = EvaluationContext(
43
+ targeting_key="test-1234",
44
+ attributes={
45
+ "email": "test-user@domain.com",
46
+ "name": "Test User",
47
+ "language": "en",
48
+ "country": "CA",
49
+ "appVersion": "1.0.11",
50
+ "appBuild": 1000,
51
+ "customData": {"custom": "data"},
52
+ "privateCustomData": {"private": "data"},
53
+ },
54
+ )
55
+
56
+ # Look up the value of the flag
57
+ if open_feature_client.get_boolean_value(FLAG_KEY, False, context):
58
+ logger.info(f"Variable {FLAG_KEY} is enabled")
59
+ else:
60
+ logger.info(f"Variable {FLAG_KEY} is not enabled")
61
+
62
+ # Fetch a JSON object variable
63
+ json_object = open_feature_client.get_object_value(
64
+ "test-json-variable", {"default": "value"}, context
65
+ )
66
+ logger.info(f"JSON Object Value: {json_object}")
67
+
68
+ # Retrieve a string variable along with resolution details
69
+ details = open_feature_client.get_string_details("doesnt-exist", "default", context)
70
+ logger.info(f"String Value: {details.value}")
71
+ logger.info(f"Eval Reason: {details.reason}")
72
+
73
+ devcycle_client.close()
74
+
75
+
76
+ if __name__ == "__main__":
77
+ main()
@@ -5,10 +5,7 @@ import unittest
5
5
  from devcycle_python_sdk.api.local_bucketing import LocalBucketing, WASMAbortError
6
6
  from devcycle_python_sdk.models.bucketed_config import (
7
7
  Environment,
8
- EdgeDBSettings,
9
8
  FeatureVariation,
10
- OptInColors,
11
- OptInSettings,
12
9
  Project,
13
10
  ProjectSettings,
14
11
  )
@@ -197,27 +194,29 @@ class LocalBucketingTest(unittest.TestCase):
197
194
  defaultValue=None,
198
195
  evalReason=None,
199
196
  ),
197
+ "float-var": Variable(
198
+ _id="61200363125123fca69d3a7a",
199
+ key="float-var",
200
+ type="Number",
201
+ value=3.14159,
202
+ isDefaulted=None,
203
+ defaultValue=None,
204
+ evalReason=None,
205
+ ),
200
206
  }
201
207
 
202
208
  self.assertIsNotNone(result)
203
209
  self.assertEqual(result.user, user)
204
210
  self.assertEqual(
205
- result.project,
206
211
  Project(
207
212
  id="61f97628ff4afcb6d057dbf0",
208
213
  key="emma-project",
209
214
  a0_organization="org_tPyJN5dvNNirKar7",
210
215
  settings=ProjectSettings(
211
- edge_db=EdgeDBSettings(enabled=False),
212
- opt_in=OptInSettings(
213
- enabled=True,
214
- title="EarlyAccess",
215
- description="Getearlyaccesstobetafeaturesbelow!",
216
- image_url="",
217
- colors=OptInColors(primary="#531cd9", secondary="#16dec0"),
218
- ),
216
+ edge_db=None, opt_in=None, disable_passthrough_rollouts=False
219
217
  ),
220
218
  ),
219
+ result.project,
221
220
  )
222
221
  self.assertEqual(
223
222
  result.environment,
@@ -250,6 +249,10 @@ class LocalBucketingTest(unittest.TestCase):
250
249
  feature="62fbf6566f1ba302829f9e32",
251
250
  variation="62fbf6566f1ba302829f9e39",
252
251
  ),
252
+ "float-var": FeatureVariation(
253
+ feature="62fbf6566f1ba302829f9e32",
254
+ variation="62fbf6566f1ba302829f9e39",
255
+ ),
253
256
  },
254
257
  )
255
258
  self.assertEqual(
@@ -0,0 +1,188 @@
1
+ import logging
2
+ import unittest
3
+
4
+ from devcycle_python_sdk.models.user import DevCycleUser
5
+
6
+ from openfeature.provider import EvaluationContext
7
+ from openfeature.exception import TargetingKeyMissingError, InvalidContextError
8
+
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class DevCycleUserTest(unittest.TestCase):
14
+ def test_create_user_from_context_no_context(self):
15
+ with self.assertRaises(TargetingKeyMissingError):
16
+ DevCycleUser.create_user_from_context(None)
17
+
18
+ def test_create_user_from_context_no_user_id(self):
19
+ with self.assertRaises(TargetingKeyMissingError):
20
+ DevCycleUser.create_user_from_context(
21
+ EvaluationContext(targeting_key=None, attributes={})
22
+ )
23
+
24
+ with self.assertRaises(TargetingKeyMissingError):
25
+ DevCycleUser.create_user_from_context(
26
+ EvaluationContext(targeting_key=None, attributes=None)
27
+ )
28
+
29
+ with self.assertRaises(TargetingKeyMissingError):
30
+ DevCycleUser.create_user_from_context(
31
+ EvaluationContext(targeting_key=None, attributes={"user_id": None})
32
+ )
33
+
34
+ def test_create_user_from_context_only_user_id(self):
35
+ test_user_id = "12345"
36
+ context = EvaluationContext(targeting_key=test_user_id, attributes=None)
37
+ user = DevCycleUser.create_user_from_context(context)
38
+ self.assertIsNotNone(user)
39
+ self.assertEqual(user.user_id, test_user_id)
40
+
41
+ context = EvaluationContext(
42
+ targeting_key=None, attributes={"user_id": test_user_id}
43
+ )
44
+ user = DevCycleUser.create_user_from_context(context)
45
+ self.assertIsNotNone(user)
46
+ self.assertEqual(user.user_id, test_user_id)
47
+
48
+ # ensure that targeting_key takes precedence over user_id
49
+ context = EvaluationContext(
50
+ targeting_key=test_user_id, attributes={"user_id": "99999"}
51
+ )
52
+ user = DevCycleUser.create_user_from_context(context)
53
+ self.assertIsNotNone(user)
54
+ self.assertEqual(user.user_id, test_user_id)
55
+
56
+ def test_create_user_from_context_with_attributes(self):
57
+ test_user_id = "12345"
58
+ context = EvaluationContext(
59
+ targeting_key=test_user_id,
60
+ attributes={
61
+ "user_id": "1234",
62
+ "email": "someone@example.com",
63
+ "name": "John Doe",
64
+ "language": "en",
65
+ "country": "US",
66
+ "appVersion": "1.0.0",
67
+ "appBuild": "1",
68
+ "deviceModel": "iPhone X21",
69
+ },
70
+ )
71
+ user = DevCycleUser.create_user_from_context(context)
72
+ self.assertIsNotNone(user)
73
+ self.assertEqual(user.user_id, test_user_id)
74
+ self.assertEqual(user.email, context.attributes["email"])
75
+ self.assertEqual(user.name, context.attributes["name"])
76
+ self.assertEqual(user.language, context.attributes["language"])
77
+ self.assertEqual(user.country, context.attributes["country"])
78
+ self.assertEqual(user.appVersion, context.attributes["appVersion"])
79
+ self.assertEqual(user.appBuild, context.attributes["appBuild"])
80
+ self.assertEqual(user.deviceModel, context.attributes["deviceModel"])
81
+
82
+ def test_create_user_from_context_with_custom_data(self):
83
+ test_user_id = "12345"
84
+ context = EvaluationContext(
85
+ targeting_key=test_user_id,
86
+ attributes={
87
+ "customData": {
88
+ "strValue": "hello",
89
+ "intValue": 123,
90
+ "floatValue": 3.1456,
91
+ "boolValue": True,
92
+ },
93
+ "privateCustomData": {
94
+ "strValue": "world",
95
+ "intValue": 789,
96
+ "floatValue": 0.0001,
97
+ "BoolValue": False,
98
+ },
99
+ },
100
+ )
101
+ user = DevCycleUser.create_user_from_context(context)
102
+ self.assertIsNotNone(user)
103
+ self.assertEqual(user.user_id, test_user_id)
104
+
105
+ self.assertEqual(
106
+ user.customData,
107
+ {
108
+ "strValue": "hello",
109
+ "intValue": 123,
110
+ "floatValue": 3.1456,
111
+ "boolValue": True,
112
+ },
113
+ )
114
+ self.assertEqual(
115
+ user.privateCustomData,
116
+ {
117
+ "strValue": "world",
118
+ "intValue": 789,
119
+ "floatValue": 0.0001,
120
+ "BoolValue": False,
121
+ },
122
+ )
123
+
124
+ def test_create_user_from_context_with_unknown_data_fields(self):
125
+ test_user_id = "12345"
126
+ context = EvaluationContext(
127
+ targeting_key=test_user_id,
128
+ attributes={
129
+ "strValue": "hello",
130
+ "intValue": 123,
131
+ "floatValue": 3.1456,
132
+ "boolValue": True,
133
+ },
134
+ )
135
+ user = DevCycleUser.create_user_from_context(context)
136
+ self.assertIsNotNone(user)
137
+ self.assertEqual(user.user_id, test_user_id)
138
+
139
+ # the fields should get reassigned to custom_data
140
+ self.assertEqual(
141
+ user.customData,
142
+ {
143
+ "strValue": "hello",
144
+ "intValue": 123,
145
+ "floatValue": 3.1456,
146
+ "boolValue": True,
147
+ },
148
+ )
149
+ self.assertEqual(user.privateCustomData, None)
150
+
151
+ def test_set_custom_value(self):
152
+ custom_data = {}
153
+ with self.assertRaises(InvalidContextError):
154
+ DevCycleUser._set_custom_value(custom_data, None, None)
155
+
156
+ custom_data = {}
157
+ DevCycleUser._set_custom_value(custom_data, "key1", None)
158
+ self.assertDictEqual(custom_data, {"key1": None})
159
+
160
+ custom_data = {}
161
+ DevCycleUser._set_custom_value(custom_data, "key1", "value1")
162
+ self.assertDictEqual(custom_data, {"key1": "value1"})
163
+
164
+ custom_data = {}
165
+ DevCycleUser._set_custom_value(custom_data, "key1", 1)
166
+ self.assertDictEqual(custom_data, {"key1": 1})
167
+
168
+ custom_data = {}
169
+ DevCycleUser._set_custom_value(custom_data, "key1", 3.1456)
170
+ self.assertDictEqual(custom_data, {"key1": 3.1456})
171
+
172
+ custom_data = {}
173
+ DevCycleUser._set_custom_value(custom_data, "key1", True)
174
+ self.assertDictEqual(custom_data, {"key1": True})
175
+
176
+ custom_data = {}
177
+ with self.assertRaises(InvalidContextError):
178
+ DevCycleUser._set_custom_value(custom_data, "map_data", {"hello": "world"})
179
+
180
+ custom_data = {}
181
+ with self.assertRaises(InvalidContextError):
182
+ DevCycleUser._set_custom_value(
183
+ custom_data, "list_data", ["one", "two", "three"]
184
+ )
185
+
186
+
187
+ if __name__ == "__main__":
188
+ unittest.main()
File without changes
@@ -0,0 +1,219 @@
1
+ import logging
2
+ import unittest
3
+ from unittest.mock import MagicMock
4
+
5
+ from openfeature.provider import EvaluationContext
6
+ from openfeature.flag_evaluation import Reason
7
+ from openfeature.exception import (
8
+ ErrorCode,
9
+ TargetingKeyMissingError,
10
+ InvalidContextError,
11
+ TypeMismatchError,
12
+ )
13
+
14
+ from devcycle_python_sdk.models.variable import Variable, TypeEnum
15
+
16
+ from devcycle_python_sdk.open_feature_provider.provider import (
17
+ DevCycleProvider,
18
+ )
19
+
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class DevCycleProviderTest(unittest.TestCase):
25
+ def setUp(self):
26
+ self.client = MagicMock()
27
+ self.client.is_initialized.return_value = True
28
+ self.provider = DevCycleProvider(self.client)
29
+
30
+ def test_resolve_details_client_not_ready(self):
31
+ self.client.is_initialized.return_value = False
32
+
33
+ details = self.provider._resolve(
34
+ "test-flag", False, EvaluationContext("test-user")
35
+ )
36
+
37
+ self.assertIsNotNone(details)
38
+ self.assertEqual(details.value, False)
39
+ self.assertEqual(details.reason, Reason.ERROR)
40
+ self.assertEqual(details.error_code, ErrorCode.PROVIDER_NOT_READY)
41
+
42
+ def test_resolve_details_client_no_user_in_context(self):
43
+ context = EvaluationContext(targeting_key=None, attributes={})
44
+
45
+ with self.assertRaises(TargetingKeyMissingError):
46
+ self.provider._resolve("test-flag", False, context)
47
+
48
+ def test_resolve_details_no_key(self):
49
+ self.client.variable.side_effect = ValueError("test error")
50
+ context = EvaluationContext(targeting_key="user-1234")
51
+ with self.assertRaises(InvalidContextError):
52
+ self.provider._resolve(None, False, context)
53
+
54
+ def test_resolve_details_no_default_value(self):
55
+ self.client.variable.side_effect = ValueError("test error")
56
+ context = EvaluationContext(targeting_key="user-1234")
57
+ with self.assertRaises(InvalidContextError):
58
+ self.provider._resolve("test-flag", None, context)
59
+
60
+ def test_resolve_details_client_returns_none(self):
61
+ self.client.variable.return_value = None
62
+ context = EvaluationContext(targeting_key="user-1234")
63
+
64
+ details = self.provider._resolve("test-flag", False, context)
65
+
66
+ self.assertIsNotNone(details)
67
+ self.assertEqual(details.value, False)
68
+ self.assertEqual(details.reason, Reason.DEFAULT)
69
+
70
+ def test_resolve_details_client_returns_default_variable(self):
71
+ self.client.variable.return_value = Variable.create_default_variable(
72
+ key="test-flag", default_value=False
73
+ )
74
+ context = EvaluationContext(targeting_key="user-1234")
75
+ details = self.provider._resolve("test-flag", False, context)
76
+
77
+ self.assertIsNotNone(details)
78
+ self.assertEqual(details.value, False)
79
+ self.assertEqual(details.reason, Reason.DEFAULT)
80
+
81
+ def test_resolve_boolean_details(self):
82
+ key = "test-flag"
83
+ variable_value = True
84
+ default_value = False
85
+
86
+ self.client.variable.return_value = Variable(
87
+ _id=None,
88
+ value=variable_value,
89
+ key=key,
90
+ type=TypeEnum.BOOLEAN,
91
+ isDefaulted=False,
92
+ defaultValue=False,
93
+ )
94
+
95
+ context = EvaluationContext(targeting_key="user-1234")
96
+ details = self.provider.resolve_boolean_details(key, default_value, context)
97
+
98
+ self.assertIsNotNone(details)
99
+ self.assertEqual(details.value, variable_value)
100
+ self.assertEqual(details.reason, Reason.TARGETING_MATCH)
101
+
102
+ def test_resolve_string_details(self):
103
+ key = "test-flag"
104
+ variable_value = "some string"
105
+ default_value = "default string"
106
+
107
+ self.client.variable.return_value = Variable(
108
+ _id=None,
109
+ value=variable_value,
110
+ key=key,
111
+ type=TypeEnum.STRING,
112
+ isDefaulted=False,
113
+ defaultValue=False,
114
+ )
115
+
116
+ context = EvaluationContext(targeting_key="user-1234")
117
+ details = self.provider.resolve_string_details(key, default_value, context)
118
+
119
+ self.assertIsNotNone(details)
120
+ self.assertEqual(details.value, variable_value)
121
+ self.assertIsInstance(details.value, str)
122
+ self.assertEqual(details.reason, Reason.TARGETING_MATCH)
123
+
124
+ def test_resolve_integer_details(self):
125
+ key = "test-flag"
126
+ variable_value = 12345
127
+ default_value = 0
128
+
129
+ self.client.variable.return_value = Variable(
130
+ _id=None,
131
+ value=variable_value,
132
+ key=key,
133
+ type=TypeEnum.STRING,
134
+ isDefaulted=False,
135
+ defaultValue=False,
136
+ )
137
+
138
+ context = EvaluationContext(targeting_key="user-1234")
139
+ details = self.provider.resolve_integer_details(key, default_value, context)
140
+
141
+ self.assertIsNotNone(details)
142
+ self.assertIsInstance(details.value, int)
143
+ self.assertEqual(details.value, variable_value)
144
+ self.assertEqual(details.reason, Reason.TARGETING_MATCH)
145
+
146
+ def test_resolve_float_details(self):
147
+ key = "test-flag"
148
+ variable_value = 3.145
149
+ default_value = 0.0
150
+
151
+ self.client.variable.return_value = Variable(
152
+ _id=None,
153
+ value=variable_value,
154
+ key=key,
155
+ type=TypeEnum.STRING,
156
+ isDefaulted=False,
157
+ defaultValue=False,
158
+ )
159
+
160
+ context = EvaluationContext(targeting_key="user-1234")
161
+ details = self.provider.resolve_float_details(key, default_value, context)
162
+
163
+ self.assertIsNotNone(details)
164
+ self.assertIsInstance(details.value, float)
165
+ self.assertEqual(details.value, variable_value)
166
+ self.assertEqual(details.reason, Reason.TARGETING_MATCH)
167
+
168
+ def test_resolve_object_details_verify_default_value(self):
169
+ key = "test-flag"
170
+ context = EvaluationContext(targeting_key="user-1234")
171
+
172
+ # Only flat dictionaries are supported as objects
173
+ # lists, primitives and nested objects are not supported
174
+ with self.assertRaises(TypeMismatchError):
175
+ self.provider.resolve_object_details(key, [], context)
176
+
177
+ with self.assertRaises(TypeMismatchError):
178
+ self.provider.resolve_object_details(key, 1234, context)
179
+
180
+ with self.assertRaises(TypeMismatchError):
181
+ self.provider.resolve_object_details(key, 3.14, context)
182
+
183
+ with self.assertRaises(TypeMismatchError):
184
+ self.provider.resolve_object_details(key, False, context)
185
+
186
+ with self.assertRaises(TypeMismatchError):
187
+ self.provider.resolve_object_details(key, "some string", context)
188
+
189
+ # test dictionaries with nested objects
190
+ with self.assertRaises(TypeMismatchError):
191
+ self.provider.resolve_object_details(
192
+ key, {"nestedMap": {"someProp": "some value"}}, context
193
+ )
194
+
195
+ def test_resolve_object_details(self):
196
+ key = "test-flag"
197
+ variable_value = {"some": "value"}
198
+ default_value = {}
199
+
200
+ self.client.variable.return_value = Variable(
201
+ _id=None,
202
+ value=variable_value,
203
+ key=key,
204
+ type=TypeEnum.STRING,
205
+ isDefaulted=False,
206
+ defaultValue=False,
207
+ )
208
+
209
+ context = EvaluationContext(targeting_key="user-1234")
210
+ details = self.provider.resolve_object_details(key, default_value, context)
211
+
212
+ self.assertIsNotNone(details)
213
+ self.assertIsInstance(details.value, dict)
214
+ self.assertDictEqual(details.value, variable_value)
215
+ self.assertEqual(details.reason, Reason.TARGETING_MATCH)
216
+
217
+
218
+ if __name__ == "__main__":
219
+ unittest.main()
@@ -0,0 +1,136 @@
1
+ import logging
2
+ import time
3
+ import unittest
4
+
5
+ import responses
6
+
7
+ from devcycle_python_sdk import DevCycleLocalClient, DevCycleLocalOptions
8
+ from test.fixture.data import small_config_json
9
+
10
+ from openfeature.provider import EvaluationContext
11
+ from openfeature.flag_evaluation import Reason
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class DevCycleProviderWithLocalSDKTest(unittest.TestCase):
17
+ """
18
+ Tests the DevCycleProvider with actual data from the Local SDK client to confirm data translations work
19
+ """
20
+
21
+ def setUp(self) -> None:
22
+ self.sdk_key = "dvc_server_949e4962-c624-4d20-a1ea-7f2501b2ba79"
23
+ self.test_config_json = small_config_json()
24
+ self.test_etag = "2f71454e-3279-4ca7-a8e7-802ce97bef43"
25
+
26
+ config_url = "http://localhost/config/v1/server/" + self.sdk_key + ".json"
27
+
28
+ responses.add(
29
+ responses.GET,
30
+ config_url,
31
+ headers={"ETag": self.test_etag},
32
+ json=self.test_config_json,
33
+ status=200,
34
+ )
35
+
36
+ self.options = DevCycleLocalOptions(
37
+ config_polling_interval_ms=5000,
38
+ config_cdn_uri="http://localhost/",
39
+ disable_custom_event_logging=True,
40
+ disable_automatic_event_logging=True,
41
+ )
42
+ self.client = None
43
+
44
+ def tearDown(self) -> None:
45
+ if self.client:
46
+ self.client.close()
47
+ responses.reset()
48
+
49
+ def setup_client(self):
50
+ self.client = DevCycleLocalClient(self.sdk_key, self.options)
51
+ while not self.client.is_initialized():
52
+ time.sleep(0.05)
53
+
54
+ @responses.activate
55
+ def test_resolve_boolean_details(self):
56
+ self.setup_client()
57
+ provider = self.client.get_openfeature_provider()
58
+
59
+ key = "a-cool-new-feature"
60
+ expected_value = True
61
+ default_value = False
62
+
63
+ context = EvaluationContext(targeting_key="1234")
64
+ details = provider.resolve_boolean_details(key, default_value, context)
65
+
66
+ self.assertIsNotNone(details)
67
+ self.assertEqual(details.value, expected_value)
68
+ self.assertEqual(details.reason, Reason.TARGETING_MATCH)
69
+
70
+ @responses.activate
71
+ def test_resolve_integer_details(self):
72
+ self.setup_client()
73
+ provider = self.client.get_openfeature_provider()
74
+
75
+ key = "num-var"
76
+ expected_value = 12345
77
+ default_value = 0
78
+
79
+ context = EvaluationContext(targeting_key="1234")
80
+ details = provider.resolve_integer_details(key, default_value, context)
81
+
82
+ self.assertIsNotNone(details)
83
+ self.assertEqual(details.value, expected_value)
84
+ self.assertEqual(details.reason, Reason.TARGETING_MATCH)
85
+
86
+ @responses.activate
87
+ def test_resolve_float_details(self):
88
+ self.setup_client()
89
+ provider = self.client.get_openfeature_provider()
90
+
91
+ key = "float-var"
92
+ expected_value = 3.14159
93
+ default_value = 0.0001
94
+
95
+ context = EvaluationContext(targeting_key="1234")
96
+ details = provider.resolve_float_details(key, default_value, context)
97
+
98
+ self.assertIsNotNone(details)
99
+ self.assertEqual(details.value, expected_value)
100
+ self.assertEqual(details.reason, Reason.TARGETING_MATCH)
101
+
102
+ @responses.activate
103
+ def test_resolve_string_details(self):
104
+ self.setup_client()
105
+ provider = self.client.get_openfeature_provider()
106
+
107
+ key = "string-var"
108
+ expected_value = "variationOn"
109
+ default_value = "default_value"
110
+
111
+ context = EvaluationContext(targeting_key="1234")
112
+ details = provider.resolve_string_details(key, default_value, context)
113
+
114
+ self.assertIsNotNone(details)
115
+ self.assertEqual(details.value, expected_value)
116
+ self.assertEqual(details.reason, Reason.TARGETING_MATCH)
117
+
118
+ @responses.activate
119
+ def test_resolve_object_details(self):
120
+ self.setup_client()
121
+ provider = self.client.get_openfeature_provider()
122
+
123
+ key = "json-var"
124
+ expected_value = {
125
+ "displayText": "This variation is on",
126
+ "showDialog": True,
127
+ "maxUsers": 100,
128
+ }
129
+ default_value = {"default": "value"}
130
+
131
+ context = EvaluationContext(targeting_key="1234")
132
+ details = provider.resolve_string_details(key, default_value, context)
133
+
134
+ self.assertIsNotNone(details)
135
+ self.assertEqual(details.value, expected_value)
136
+ self.assertEqual(details.reason, Reason.TARGETING_MATCH)
test/test_local_client.py CHANGED
@@ -217,6 +217,7 @@ class DevCycleLocalClientTest(unittest.TestCase):
217
217
  ("string-var", "default_value", "variationOn", TypeEnum.STRING),
218
218
  ("a-cool-new-feature", False, True, TypeEnum.BOOLEAN),
219
219
  ("num-var", 0, 12345, TypeEnum.NUMBER),
220
+ ("float-var", 0, 3.14159, TypeEnum.NUMBER),
220
221
  (
221
222
  "json-var",
222
223
  {"default": "value"},
@@ -333,6 +334,15 @@ class DevCycleLocalClientTest(unittest.TestCase):
333
334
  defaultValue=None,
334
335
  evalReason=None,
335
336
  ),
337
+ "float-var": Variable(
338
+ _id="61200363125123fca69d3a7a",
339
+ key="float-var",
340
+ type="Number",
341
+ value=3.14159,
342
+ isDefaulted=None,
343
+ defaultValue=None,
344
+ evalReason=None,
345
+ ),
336
346
  }
337
347
  self.assertEqual(result, expected_variables)
338
348
 
test/util/test_version.py CHANGED
@@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
10
10
  class VersionTest(unittest.TestCase):
11
11
  def test_sdk_version(self):
12
12
  version = sdk_version()
13
- self.assertRegexpMatches(version, r"^\d+\.\d+\.\d+$")
13
+ self.assertRegex(version, r"^\d+\.\d+\.\d+$")
14
14
 
15
15
 
16
16
  if __name__ == "__main__":