devcycle-python-server-sdk 2.0.0__py3-none-any.whl → 3.0.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.
Files changed (51) hide show
  1. devcycle_python_sdk/VERSION.txt +1 -1
  2. devcycle_python_sdk/__init__.py +3 -2
  3. devcycle_python_sdk/api/backoff.py +11 -0
  4. devcycle_python_sdk/api/bucketing_client.py +17 -25
  5. devcycle_python_sdk/api/config_client.py +116 -0
  6. devcycle_python_sdk/api/event_client.py +95 -0
  7. devcycle_python_sdk/api/local_bucketing.py +436 -0
  8. devcycle_python_sdk/{dvc_cloud_client.py → cloud_client.py} +69 -41
  9. devcycle_python_sdk/exceptions.py +25 -3
  10. devcycle_python_sdk/local_client.py +220 -0
  11. devcycle_python_sdk/managers/__init__.py +0 -0
  12. devcycle_python_sdk/managers/config_manager.py +95 -0
  13. devcycle_python_sdk/managers/event_queue_manager.py +223 -0
  14. devcycle_python_sdk/models/bucketed_config.py +150 -0
  15. devcycle_python_sdk/models/event.py +111 -8
  16. devcycle_python_sdk/models/feature.py +12 -0
  17. devcycle_python_sdk/models/platform_data.py +33 -0
  18. devcycle_python_sdk/models/user.py +52 -7
  19. devcycle_python_sdk/models/variable.py +17 -5
  20. devcycle_python_sdk/options.py +106 -0
  21. devcycle_python_sdk/protobuf/__init__.py +0 -0
  22. devcycle_python_sdk/protobuf/utils.py +129 -0
  23. devcycle_python_sdk/protobuf/variableForUserParams_pb2.py +46 -0
  24. devcycle_python_sdk/py.typed +0 -0
  25. devcycle_python_sdk/util/version.py +1 -1
  26. {devcycle_python_server_sdk-2.0.0.dist-info → devcycle_python_server_sdk-3.0.0.dist-info}/METADATA +3 -2
  27. devcycle_python_server_sdk-3.0.0.dist-info/RECORD +55 -0
  28. example/{example.py → cloud_client_example.py} +24 -20
  29. example/local_bucketing_client_example.py +74 -0
  30. test/api/__init__.py +0 -0
  31. test/{test_bucketing_client.py → api/test_bucketing_client.py} +10 -5
  32. test/api/test_config_client.py +133 -0
  33. test/api/test_event_client.py +97 -0
  34. test/api/test_local_bucketing.py +392 -0
  35. test/fixture/__init__.py +0 -0
  36. test/fixture/data.py +57 -0
  37. test/managers/__init__.py +0 -0
  38. test/managers/test_config_manager.py +132 -0
  39. test/managers/test_event_queue_manager.py +271 -0
  40. test/models/__init__.py +0 -0
  41. test/models/test_bucketed_config.py +26 -0
  42. test/{test_dvc_cloud_client.py → test_cloud_client.py} +11 -17
  43. test/test_local_client.py +397 -0
  44. test/util/__init__.py +0 -0
  45. test/util/test_utils.py +183 -0
  46. test/{test_version.py → util/test_version.py} +2 -2
  47. devcycle_python_sdk/dvc_options.py +0 -18
  48. devcycle_python_server_sdk-2.0.0.dist-info/RECORD +0 -26
  49. {devcycle_python_server_sdk-2.0.0.dist-info → devcycle_python_server_sdk-3.0.0.dist-info}/LICENSE +0 -0
  50. {devcycle_python_server_sdk-2.0.0.dist-info → devcycle_python_server_sdk-3.0.0.dist-info}/WHEEL +0 -0
  51. {devcycle_python_server_sdk-2.0.0.dist-info → devcycle_python_server_sdk-3.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,220 @@
1
+ import json
2
+ import logging
3
+ from numbers import Real
4
+ from typing import Any, Dict, Union
5
+
6
+ from devcycle_python_sdk import DevCycleLocalOptions
7
+ from devcycle_python_sdk.api.local_bucketing import LocalBucketing
8
+ from devcycle_python_sdk.exceptions import VariableTypeMismatchError
9
+ from devcycle_python_sdk.managers.config_manager import EnvironmentConfigManager
10
+ from devcycle_python_sdk.managers.event_queue_manager import EventQueueManager
11
+ from devcycle_python_sdk.models.bucketed_config import BucketedConfig
12
+ from devcycle_python_sdk.models.event import DevCycleEvent, EventType
13
+ from devcycle_python_sdk.models.feature import Feature
14
+ from devcycle_python_sdk.models.platform_data import default_platform_data
15
+ from devcycle_python_sdk.models.user import DevCycleUser
16
+ from devcycle_python_sdk.models.variable import Variable
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class DevCycleLocalClient:
22
+ def __init__(self, sdk_key: str, options: DevCycleLocalOptions):
23
+ _validate_sdk_key(sdk_key)
24
+ self._sdk_key = sdk_key
25
+
26
+ if options is None:
27
+ self.options: DevCycleLocalOptions = DevCycleLocalOptions()
28
+ else:
29
+ self.options = options
30
+
31
+ self.local_bucketing = LocalBucketing(sdk_key)
32
+
33
+ self._platform_data = default_platform_data()
34
+ self.local_bucketing.set_platform_data(
35
+ json.dumps(self._platform_data.to_json())
36
+ )
37
+
38
+ self.config_manager: EnvironmentConfigManager = EnvironmentConfigManager(
39
+ sdk_key, self.options, self.local_bucketing
40
+ )
41
+ self.event_queue_manager: EventQueueManager = EventQueueManager(
42
+ sdk_key, self.options, self.local_bucketing
43
+ )
44
+
45
+ def is_initialized(self) -> bool:
46
+ return self.config_manager.is_initialized()
47
+
48
+ def set_client_custom_data(
49
+ self, custom_data: Dict[str, Union[str, Real, bool, None]]
50
+ ) -> None:
51
+ """
52
+ Sets global custom data for this client. This data will be utilized in all segmentation and bucketing
53
+ decisions. This data will be merged with any custom data set on the user object, with user data
54
+ taking priority
55
+
56
+ :param custom_data: Global data to set. Supported values are strings, numbers, booleans. Nested dictionaries
57
+ are not permitted
58
+ """
59
+ if not self.is_initialized():
60
+ logger.debug("set_client_custom_data called before client has initialized")
61
+ return
62
+
63
+ if custom_data:
64
+ try:
65
+ custom_data_json = json.dumps(custom_data)
66
+ self.local_bucketing.set_client_custom_data(custom_data_json)
67
+ except Exception as e:
68
+ logger.error("Error setting custom data: " + str(e))
69
+
70
+ def variable_value(self, user: DevCycleUser, key: str, default_value: Any) -> Any:
71
+ """
72
+ 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
73
+
74
+ :param user: The user to evaluate the variable for
75
+ """
76
+ return self.variable(user, key, default_value).value
77
+
78
+ def variable(self, user: DevCycleUser, key: str, default_value: Any) -> Variable:
79
+ """
80
+ Evaluates a variable for a user.
81
+
82
+ :param user: The user to evaluate the variable for
83
+ :param key: The key of the variable to evaluate
84
+ :param default_value: The default value to return if the user is not bucketed into the variable
85
+ """
86
+ _validate_user(user)
87
+
88
+ if not key:
89
+ raise ValueError("Missing parameter: key")
90
+
91
+ if default_value is None:
92
+ raise ValueError("Missing parameter: defaultValue")
93
+
94
+ if not self.is_initialized():
95
+ logger.debug("variable called before client has initialized")
96
+ try:
97
+ self.event_queue_manager.queue_aggregate_event(
98
+ event=DevCycleEvent(
99
+ type=EventType.AggVariableDefaulted, target=key, value=1
100
+ ),
101
+ bucketed_config=None,
102
+ )
103
+ except Exception as e:
104
+ logger.warning(
105
+ f"Unable to track AggVariableDefaulted event for Variable {key}: {e}"
106
+ )
107
+ return Variable.create_default_variable(key, default_value)
108
+
109
+ try:
110
+ variable = self.local_bucketing.get_variable_for_user_protobuf(
111
+ user, key, default_value
112
+ )
113
+ if variable:
114
+ return variable
115
+ except VariableTypeMismatchError:
116
+ logger.debug("Variable type mismatch, returning default value")
117
+ except Exception as e:
118
+ logger.warning(f"Error retrieving variable for user: {e}")
119
+
120
+ return Variable.create_default_variable(key, default_value)
121
+
122
+ def _generate_bucketed_config(self, user: DevCycleUser) -> BucketedConfig:
123
+ """
124
+ Generates a bucketed config for a user. This method will return an empty config if the client has not been initialized or if the user is not bucketed into any features or variables
125
+ """
126
+
127
+ _validate_user(user)
128
+
129
+ return self.local_bucketing.generate_bucketed_config(user)
130
+
131
+ def all_variables(self, user: DevCycleUser) -> Dict[str, Variable]:
132
+ """
133
+ Returns all segmented and bucketed variables for a user. This method will return an empty map if the client has not been initialized or if the user is not bucketed into any variables
134
+
135
+ :param user: The user to retrieve variables for
136
+ """
137
+ _validate_user(user)
138
+
139
+ if not self.is_initialized():
140
+ logger.warning("all_variables called before client has initialized")
141
+ return {}
142
+
143
+ variable_map: Dict[str, Variable] = {}
144
+
145
+ try:
146
+ return self.local_bucketing.generate_bucketed_config(user).variables
147
+ except Exception as e:
148
+ logger.exception(f"Error retrieving all variables for a user: {e}")
149
+ return {}
150
+
151
+ return variable_map
152
+
153
+ def all_features(self, user: DevCycleUser) -> Dict[str, Feature]:
154
+ """
155
+ Returns all segmented and bucketed features for a user. This method will return an empty map if the client has not been initialized or if the user is not bucketed into any features
156
+
157
+ :param user: The user to retrieve features for
158
+ """
159
+ _validate_user(user)
160
+
161
+ if not self.is_initialized():
162
+ logger.warning("all_features called before client has initialized")
163
+ return {}
164
+
165
+ feature_map: Dict[str, Feature] = {}
166
+ try:
167
+ return self.local_bucketing.generate_bucketed_config(user).features
168
+ except Exception as e:
169
+ logger.exception(f"Error retrieving all features for a user: {e}")
170
+
171
+ return feature_map
172
+
173
+ def track(self, user: DevCycleUser, user_event: DevCycleEvent) -> None:
174
+ """
175
+ Tracks a custom event for a user. This method will return immediately and the event will be queued for processing in the background. If the client has not been initialized, this method will return immediately and the event will be discarded.
176
+
177
+ :param user: The user to track the event for
178
+ :param user_event: The event to track
179
+ """
180
+ _validate_user(user)
181
+
182
+ if user_event is None:
183
+ raise ValueError("Invalid Event")
184
+
185
+ if user_event.type is None or len(user_event.type) == 0:
186
+ raise ValueError("Missing parameter: type")
187
+
188
+ if not self.is_initialized():
189
+ logger.debug("track called before client has initialized")
190
+ return
191
+
192
+ try:
193
+ self.event_queue_manager.queue_event(user, user_event)
194
+ except Exception as e:
195
+ logger.error(f"Error tracking event: {e}")
196
+
197
+ def close(self) -> None:
198
+ """
199
+ Closes the client and releases any resources held by it.
200
+ """
201
+ self.config_manager.close()
202
+ self.event_queue_manager.close()
203
+
204
+
205
+ def _validate_sdk_key(sdk_key: str) -> None:
206
+ if sdk_key is None or len(sdk_key) == 0:
207
+ raise ValueError("Missing SDK key! Call initialize with a valid SDK key")
208
+
209
+ if not sdk_key.startswith("server") and not sdk_key.startswith("dvc_server"):
210
+ raise ValueError(
211
+ "Invalid SDK key provided. Please call initialize with a valid server SDK key"
212
+ )
213
+
214
+
215
+ def _validate_user(user: DevCycleUser) -> None:
216
+ if user is None:
217
+ raise ValueError("User cannot be None")
218
+
219
+ if user.user_id is None or len(user.user_id) == 0:
220
+ raise ValueError("userId cannot be empty")
File without changes
@@ -0,0 +1,95 @@
1
+ import threading
2
+ import time
3
+ import logging
4
+ import json
5
+ from typing import Optional
6
+
7
+ from devcycle_python_sdk.options import DevCycleLocalOptions
8
+ from devcycle_python_sdk.api.local_bucketing import LocalBucketing
9
+ from devcycle_python_sdk.api.config_client import ConfigAPIClient
10
+ from devcycle_python_sdk.exceptions import (
11
+ CloudClientUnauthorizedError,
12
+ CloudClientError,
13
+ )
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class EnvironmentConfigManager(threading.Thread):
19
+ def __init__(
20
+ self,
21
+ sdk_key: str,
22
+ options: DevCycleLocalOptions,
23
+ local_bucketing: LocalBucketing,
24
+ ):
25
+ super().__init__()
26
+
27
+ self._sdk_key = sdk_key
28
+ self._options = options
29
+ self._local_bucketing = local_bucketing
30
+
31
+ self._config: Optional[dict] = None
32
+ self._config_etag: Optional[str] = None
33
+
34
+ self._config_api_client = ConfigAPIClient(self._sdk_key, self._options)
35
+
36
+ self._polling_enabled = True
37
+ self.daemon = True
38
+ self.start()
39
+
40
+ def is_initialized(self) -> bool:
41
+ return self._config is not None
42
+
43
+ def _get_config(self):
44
+ try:
45
+ new_config, new_etag = self._config_api_client.get_config(
46
+ config_etag=self._config_etag
47
+ )
48
+
49
+ if new_config is None and new_etag == self._config_etag:
50
+ # api not returning data and the etag is the same
51
+ # no change to the config since last request
52
+ return
53
+ elif new_config is None:
54
+ logger.warning(
55
+ "Config CDN fetch returned no data with a different etag"
56
+ )
57
+ return
58
+
59
+ trigger_on_client_initialized = self._config is None
60
+
61
+ self._config = new_config
62
+ self._config_etag = new_etag
63
+
64
+ json_config = json.dumps(self._config)
65
+ self._local_bucketing.store_config(json_config)
66
+
67
+ if (
68
+ trigger_on_client_initialized
69
+ and self._options.on_client_initialized is not None
70
+ ):
71
+ try:
72
+ self._options.on_client_initialized()
73
+ except Exception as e:
74
+ # consume any error
75
+ logger.warning(
76
+ f"Error received from on_client_initialized callback: {str(e)}"
77
+ )
78
+ except CloudClientError as e:
79
+ logger.warning(f"Config fetch failed. Status: {str(e)}")
80
+ except CloudClientUnauthorizedError:
81
+ logger.error("Unauthorized to get config. Aborting config polling.")
82
+ self._polling_enabled = False
83
+
84
+ def run(self):
85
+ while self._polling_enabled:
86
+ try:
87
+ self._get_config()
88
+ except Exception as e:
89
+ if self._polling_enabled:
90
+ # Only log a warning if we're still polling
91
+ logger.warning(f"Error polling for config changes: {str(e)}")
92
+ time.sleep(self._options.config_polling_interval_ms / 1000.0)
93
+
94
+ def close(self):
95
+ self._polling_enabled = False
@@ -0,0 +1,223 @@
1
+ import threading
2
+ import logging
3
+ import json
4
+ from typing import Optional
5
+
6
+ from devcycle_python_sdk.options import DevCycleLocalOptions
7
+ from devcycle_python_sdk.api.local_bucketing import LocalBucketing
8
+ from devcycle_python_sdk.api.event_client import EventAPIClient
9
+ from devcycle_python_sdk.exceptions import (
10
+ APIClientError,
11
+ APIClientUnauthorizedError,
12
+ NotFoundError,
13
+ )
14
+ from devcycle_python_sdk.models.event import (
15
+ FlushPayload,
16
+ DevCycleEvent,
17
+ EventType,
18
+ )
19
+ from devcycle_python_sdk.models.user import DevCycleUser
20
+ from devcycle_python_sdk.models.bucketed_config import BucketedConfig
21
+
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class QueueFullError(Exception):
27
+ pass
28
+
29
+
30
+ class EventQueueManager(threading.Thread):
31
+ def __init__(
32
+ self,
33
+ sdk_key: str,
34
+ options: DevCycleLocalOptions,
35
+ local_bucketing: LocalBucketing,
36
+ ):
37
+ super().__init__()
38
+
39
+ if sdk_key is None or sdk_key == "":
40
+ raise ValueError("DevCycle is not yet initialized to publish events.")
41
+
42
+ self._sdk_key = sdk_key
43
+ self._options = options
44
+ self._local_bucketing = local_bucketing
45
+ self._event_api_client = EventAPIClient(self._sdk_key, self._options)
46
+ self._flush_lock = threading.Lock()
47
+ self._exit = threading.Event()
48
+ self._exited = threading.Event()
49
+
50
+ # Setup the event queue inside the WASM module
51
+ event_options_json = json.dumps(self._options.event_queue_options())
52
+ self._local_bucketing.init_event_queue(event_options_json)
53
+
54
+ # Only start event processing thread if event logging is enabled
55
+ if not (
56
+ self._options.disable_custom_event_logging
57
+ and self._options.disable_automatic_event_logging
58
+ ):
59
+ self.daemon = True
60
+ self.start()
61
+ else:
62
+ self._stop_running()
63
+ self._mark_exited()
64
+
65
+ def _should_run(self) -> bool:
66
+ # Returns true if the thread should continue running, false otherwise
67
+ return not self._exit.is_set()
68
+
69
+ def _stop_running(self) -> None:
70
+ # Indicate to the thread that it should stop running, interrupting any sleep
71
+ self._exit.set()
72
+
73
+ def _sleep(self) -> bool:
74
+ # Returns true if the sleep was interrupted, false otherwise
75
+ return self._exit.wait(self._options.event_flush_interval_ms / 1000.0)
76
+
77
+ def _mark_exited(self) -> None:
78
+ # Indicate to the thread calling close that the thread has exited
79
+ self._exited.set()
80
+
81
+ def _wait_for_exit(self, timeout_seconds: float) -> bool:
82
+ # Wait up to timeout_seconds for the thread to exit
83
+ # This works whether the thread was actually started or not
84
+ return self._exited.wait(timeout_seconds)
85
+
86
+ def _flush_events(self) -> int:
87
+ if self._flush_lock.locked():
88
+ return 0
89
+
90
+ with self._flush_lock:
91
+ try:
92
+ payloads = self._local_bucketing.flush_event_queue()
93
+ except Exception as e:
94
+ logger.error(f"Error flushing event payloads: {str(e)}")
95
+
96
+ event_count = 0
97
+ if payloads:
98
+ logger.debug(f"Flush {len(payloads)} event payloads")
99
+ for payload in payloads:
100
+ event_count += payload.eventCount
101
+ self._publish_event_payload(payload)
102
+ logger.debug(f"Flush {event_count} events, for {len(payloads)} users")
103
+ return event_count
104
+
105
+ def _publish_event_payload(self, payload: FlushPayload) -> None:
106
+ if payload and payload.records:
107
+ try:
108
+ self._event_api_client.publish_events(payload.records)
109
+ self._local_bucketing.on_event_payload_success(payload.payloadId)
110
+ except APIClientUnauthorizedError:
111
+ logger.error(
112
+ "Unauthorized to publish events, please check your SDK key"
113
+ )
114
+ # stop the thread
115
+ self._stop_running()
116
+ self._local_bucketing.on_event_payload_failure(payload.payloadId, False)
117
+ except NotFoundError as e:
118
+ logger.error(
119
+ f"Unable to reach the DevCycle Events API service: {str(e)}"
120
+ )
121
+ self._stop_running()
122
+ self._local_bucketing.on_event_payload_failure(payload.payloadId, False)
123
+ except APIClientError as e:
124
+ logger.warning(
125
+ f"Error publishing events to DevCycle Events API service: {str(e)}"
126
+ )
127
+ self._local_bucketing.on_event_payload_failure(payload.payloadId, True)
128
+
129
+ def is_event_logging_disabled(self, event_type: str) -> bool:
130
+ if event_type in [
131
+ EventType.VariableDefaulted,
132
+ EventType.VariableEvaluated,
133
+ EventType.AggVariableDefaulted,
134
+ EventType.AggVariableEvaluated,
135
+ ]:
136
+ return self._options.disable_automatic_event_logging
137
+ else:
138
+ return self._options.disable_custom_event_logging
139
+
140
+ def run(self):
141
+ while self._should_run():
142
+ try:
143
+ self._flush_events()
144
+ except Exception as e:
145
+ logger.warning(f"Error flushing events: {str(e)}")
146
+
147
+ self._sleep()
148
+
149
+ self._mark_exited()
150
+
151
+ def close(self):
152
+ self._stop_running()
153
+
154
+ # Wait up to 1s for the thread to exit.
155
+ # Because the sleeping between batches is interruptible, this is only
156
+ # providing time for an in-flight batch to finish.
157
+ if not self._wait_for_exit(1.0):
158
+ logger.error("Timed out waiting for event flushing thread to stop")
159
+
160
+ try:
161
+ self._flush_events()
162
+ except Exception as e:
163
+ logger.warning(f"DVC Error flushing events when closing client: {str(e)}")
164
+
165
+ def queue_event(self, user: DevCycleUser, event: DevCycleEvent) -> None:
166
+ if user is None:
167
+ raise ValueError("user cannot be None")
168
+
169
+ if event is None:
170
+ raise ValueError("event cannot be None")
171
+
172
+ if not event.type:
173
+ raise ValueError("event type not set")
174
+
175
+ try:
176
+ self._check_queue_status()
177
+ except QueueFullError:
178
+ logger.warning("Event queue is full, dropping user event")
179
+ return
180
+
181
+ user_json = json.dumps(user.to_json())
182
+ event_json = json.dumps(event.to_json())
183
+ self._local_bucketing.queue_event(user_json, event_json)
184
+
185
+ def queue_aggregate_event(
186
+ self, event: DevCycleEvent, bucketed_config: Optional[BucketedConfig]
187
+ ) -> None:
188
+ if event is None:
189
+ raise ValueError("event cannot be None")
190
+
191
+ if not event.type:
192
+ raise ValueError("event type not set")
193
+
194
+ if not event.target:
195
+ raise ValueError("event target not set")
196
+
197
+ try:
198
+ self._check_queue_status()
199
+ except QueueFullError:
200
+ logger.warning("Event queue is full, dropping aggregate event")
201
+ return
202
+
203
+ event_json = json.dumps(event.to_json())
204
+ if bucketed_config and bucketed_config.variable_variation_map:
205
+ variation_map_json = json.dumps(bucketed_config.variable_variation_map)
206
+ else:
207
+ variation_map_json = "{}"
208
+ self._local_bucketing.queue_aggregate_event(event_json, variation_map_json)
209
+
210
+ def _check_queue_status(self) -> None:
211
+ if self._flush_needed():
212
+ self._flush_events()
213
+
214
+ if self._queue_full():
215
+ raise QueueFullError()
216
+
217
+ def _flush_needed(self) -> bool:
218
+ queue_size = self._local_bucketing.get_event_queue_size()
219
+ return queue_size >= self._options.flush_event_queue_size
220
+
221
+ def _queue_full(self) -> bool:
222
+ queue_size = self._local_bucketing.get_event_queue_size()
223
+ return queue_size >= self._options.max_event_queue_size
@@ -0,0 +1,150 @@
1
+ # ruff: noqa: N815
2
+ from dataclasses import dataclass
3
+ from typing import Dict, List
4
+ from typing import Optional
5
+
6
+ from .user import DevCycleUser
7
+ from .variable import Variable
8
+ from .feature import Feature
9
+
10
+
11
+ @dataclass
12
+ class EdgeDBSettings:
13
+ enabled: bool
14
+
15
+ @classmethod
16
+ def from_json(cls, data: dict) -> "EdgeDBSettings":
17
+ return cls(
18
+ enabled=data.get("enabled", False),
19
+ )
20
+
21
+
22
+ @dataclass
23
+ class OptInColors:
24
+ primary: str
25
+ secondary: str
26
+
27
+ @classmethod
28
+ def from_json(cls, data: dict) -> "OptInColors":
29
+ return cls(
30
+ primary=data.get("primary", ""),
31
+ secondary=data.get("secondary", ""),
32
+ )
33
+
34
+
35
+ @dataclass
36
+ class OptInSettings:
37
+ enabled: bool
38
+ title: str
39
+ description: str
40
+ image_url: str
41
+ colors: OptInColors
42
+
43
+ @classmethod
44
+ def from_json(cls, data: dict) -> "OptInSettings":
45
+ return cls(
46
+ enabled=data.get("enabled", False),
47
+ title=data.get("title", ""),
48
+ description=data.get("description", ""),
49
+ image_url=data.get("imageURL", ""),
50
+ colors=OptInColors.from_json(data.get("colors", "")),
51
+ )
52
+
53
+
54
+ @dataclass
55
+ class ProjectSettings:
56
+ edge_db: Optional[EdgeDBSettings]
57
+ opt_in: Optional[OptInSettings]
58
+
59
+ @classmethod
60
+ def from_json(cls, data: dict) -> "ProjectSettings":
61
+ return cls(
62
+ edge_db=EdgeDBSettings.from_json(data["edgeDB"])
63
+ if "edgeDB" in data
64
+ else None,
65
+ opt_in=OptInSettings.from_json(data["optIn"]) if "optIn" in data else None,
66
+ )
67
+
68
+
69
+ @dataclass
70
+ class Project:
71
+ id: str
72
+ key: str
73
+ a0_organization: str
74
+ settings: Optional[ProjectSettings]
75
+
76
+ @classmethod
77
+ def from_json(cls, data: dict) -> "Project":
78
+ return cls(
79
+ id=data["_id"],
80
+ key=data["key"],
81
+ a0_organization=data["a0_organization"],
82
+ settings=ProjectSettings.from_json(data["settings"])
83
+ if "settings" in data
84
+ else None,
85
+ )
86
+
87
+
88
+ @dataclass
89
+ class Environment:
90
+ id: str
91
+ key: str
92
+
93
+ @classmethod
94
+ def from_json(cls, data: dict) -> "Environment":
95
+ return cls(
96
+ id=data["_id"],
97
+ key=data["key"],
98
+ )
99
+
100
+
101
+ @dataclass
102
+ class FeatureVariation:
103
+ feature: str
104
+ variation: str
105
+
106
+ @classmethod
107
+ def from_json(cls, data: dict) -> "FeatureVariation":
108
+ return cls(
109
+ feature=data["_feature"],
110
+ variation=data["_variation"],
111
+ )
112
+
113
+
114
+ @dataclass(order=False)
115
+ class BucketedConfig:
116
+ project: Project
117
+ environment: Environment
118
+ features: Dict[str, Feature]
119
+ feature_variation_map: Dict[str, str]
120
+ variable_variation_map: Dict[str, FeatureVariation]
121
+ variables: Dict[str, Variable]
122
+ known_variable_keys: List[float]
123
+
124
+ user: Optional[DevCycleUser] = None
125
+
126
+ def to_json(self):
127
+ return {
128
+ key: getattr(self, key)
129
+ for key in self.__dataclass_fields__
130
+ if getattr(self, key) is not None
131
+ }
132
+
133
+ @classmethod
134
+ def from_json(cls, data: dict) -> "BucketedConfig":
135
+ return cls(
136
+ project=Project.from_json(data["project"]),
137
+ environment=Environment.from_json(data["environment"]),
138
+ features={
139
+ k: Feature.from_json(v) for k, v in data.get("features", {}).items()
140
+ },
141
+ feature_variation_map=data.get("featureVariationMap", {}),
142
+ variable_variation_map={
143
+ k: FeatureVariation.from_json(v)
144
+ for k, v in data.get("variableVariationMap", {}).items()
145
+ },
146
+ variables={
147
+ k: Variable.from_json(v) for k, v in data.get("variables", {}).items()
148
+ },
149
+ known_variable_keys=data.get("knownVariableKeys", []),
150
+ )