devcycle-python-server-sdk 3.3.3__tar.gz → 3.6.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.
Files changed (70) hide show
  1. devcycle-python-server-sdk-3.6.0/LICENSE +21 -0
  2. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/PKG-INFO +1 -1
  3. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/README.md +17 -2
  4. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/__init__.py +1 -0
  5. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/api/bucketing_client.py +2 -2
  6. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/api/config_client.py +22 -8
  7. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/api/event_client.py +2 -2
  8. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/api/local_bucketing.py +1 -1
  9. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/cloud_client.py +29 -12
  10. devcycle-python-server-sdk-3.6.0/devcycle_python_sdk/devcycle_client.py +40 -0
  11. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/local_client.py +36 -14
  12. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/managers/config_manager.py +16 -7
  13. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/managers/event_queue_manager.py +15 -11
  14. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/models/bucketed_config.py +10 -6
  15. devcycle-python-server-sdk-3.6.0/devcycle_python_sdk/models/user.py +159 -0
  16. devcycle-python-server-sdk-3.6.0/devcycle_python_sdk/open_feature_provider/provider.py +125 -0
  17. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/options.py +5 -5
  18. devcycle-python-server-sdk-3.6.0/devcycle_python_sdk/util/strings.py +6 -0
  19. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_server_sdk.egg-info/PKG-INFO +1 -1
  20. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_server_sdk.egg-info/SOURCES.txt +11 -0
  21. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_server_sdk.egg-info/requires.txt +2 -1
  22. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/example/local_bucketing_client_example.py +1 -1
  23. devcycle-python-server-sdk-3.6.0/example/openfeature_example.py +77 -0
  24. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/pyproject.toml +17 -0
  25. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/test/api/test_bucketing_client.py +5 -1
  26. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/test/api/test_config_client.py +29 -8
  27. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/test/api/test_event_client.py +3 -0
  28. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/test/api/test_local_bucketing.py +15 -12
  29. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/test/fixture/data.py +5 -5
  30. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/test/managers/test_config_manager.py +32 -10
  31. devcycle-python-server-sdk-3.6.0/test/models/test_user.py +188 -0
  32. devcycle-python-server-sdk-3.6.0/test/openfeature_test/__init__.py +0 -0
  33. devcycle-python-server-sdk-3.6.0/test/openfeature_test/test_provider.py +219 -0
  34. devcycle-python-server-sdk-3.6.0/test/openfeature_test/test_provider_local_sdk.py +136 -0
  35. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/test/test_local_client.py +10 -0
  36. devcycle-python-server-sdk-3.6.0/test/util/__init__.py +0 -0
  37. devcycle-python-server-sdk-3.6.0/test/util/test_strings.py +25 -0
  38. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/test/util/test_version.py +1 -1
  39. devcycle-python-server-sdk-3.3.3/devcycle_python_sdk/models/user.py +0 -76
  40. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/api/__init__.py +0 -0
  41. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/api/backoff.py +0 -0
  42. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/exceptions.py +0 -0
  43. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/managers/__init__.py +0 -0
  44. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/models/__init__.py +0 -0
  45. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/models/error_response.py +0 -0
  46. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/models/event.py +0 -0
  47. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/models/feature.py +0 -0
  48. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/models/platform_data.py +0 -0
  49. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/models/variable.py +0 -0
  50. {devcycle-python-server-sdk-3.3.3/devcycle_python_sdk/protobuf → devcycle-python-server-sdk-3.6.0/devcycle_python_sdk/open_feature_provider}/__init__.py +0 -0
  51. {devcycle-python-server-sdk-3.3.3/example → devcycle-python-server-sdk-3.6.0/devcycle_python_sdk/protobuf}/__init__.py +0 -0
  52. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/protobuf/utils.py +0 -0
  53. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/protobuf/variableForUserParams_pb2.py +0 -0
  54. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/util/__init__.py +0 -0
  55. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/util/version.py +0 -0
  56. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_server_sdk.egg-info/dependency_links.txt +0 -0
  57. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_server_sdk.egg-info/top_level.txt +0 -0
  58. {devcycle-python-server-sdk-3.3.3/test → devcycle-python-server-sdk-3.6.0/example}/__init__.py +0 -0
  59. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/example/cloud_client_example.py +0 -0
  60. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/setup.cfg +0 -0
  61. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/setup.py +0 -0
  62. {devcycle-python-server-sdk-3.3.3/test/api → devcycle-python-server-sdk-3.6.0/test}/__init__.py +0 -0
  63. {devcycle-python-server-sdk-3.3.3/test/fixture → devcycle-python-server-sdk-3.6.0/test/api}/__init__.py +0 -0
  64. {devcycle-python-server-sdk-3.3.3/test/managers → devcycle-python-server-sdk-3.6.0/test/fixture}/__init__.py +0 -0
  65. {devcycle-python-server-sdk-3.3.3/test/models → devcycle-python-server-sdk-3.6.0/test/managers}/__init__.py +0 -0
  66. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/test/managers/test_event_queue_manager.py +0 -0
  67. {devcycle-python-server-sdk-3.3.3/test/util → devcycle-python-server-sdk-3.6.0/test/models}/__init__.py +0 -0
  68. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/test/models/test_bucketed_config.py +0 -0
  69. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/test/test_cloud_client.py +0 -0
  70. {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/test/util/test_utils.py +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 Taplytics Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 1.0
2
2
  Name: devcycle-python-server-sdk
3
- Version: 3.3.3
3
+ Version: 3.6.0
4
4
  Summary: DevCycle Python SDK
5
5
  Home-page: https://github.com/devcycleHQ/python-server-sdk
6
6
  Author: UNKNOWN
@@ -6,7 +6,7 @@ This SDK allows your application to interface with the [DevCycle Bucketing API](
6
6
 
7
7
  ## Requirements.
8
8
 
9
- * Python 3.7+
9
+ * Python 3.8+
10
10
 
11
11
  ## Installation
12
12
 
@@ -39,13 +39,28 @@ value = client.variable_value(user, 'variable-key', 'default-value')
39
39
 
40
40
  The DevCycle client is designed to work as a singleton in your application. You should create a single instance of the client during application initialization
41
41
 
42
+ ## OpenFeature Support
43
+
44
+ This SDK provides an alpha implementation of the [OpenFeature](https://openfeature.dev/) Provider interface. Use the `get_openfeature_provider()` function on the DevCycle SDK client to obtain a provider for OpenFeature.
45
+
46
+ ```python
47
+ from openfeature import api
48
+
49
+ devcycle_client = DevCycleLocalClient('DEVCYCLE_SERVER_SDK_KEY', options)
50
+ api.set_provider(devcycle_client.get_openfeature_provider())
51
+ ```
52
+
53
+ More details are in the [DevCycle Python SDK OpenFeature Provider](OpenFeature.md) guide.
54
+
55
+ > :warning: **OpenFeature support is in an early release and may have some rough edges**. Please report any issues to us and we'll be happy to help!
56
+
42
57
  ## Usage
43
58
 
44
59
  To find usage documentation, visit our [docs](https://docs.devcycle.com/docs/sdk/server-side-sdks/python#usage).
45
60
 
46
61
  ## Development
47
62
 
48
- When developing the SDK it is recommended that you have both a 3.7 and 3.11 python interpreter installed in order to verify changes across different versions of python.
63
+ When developing the SDK it is recommended that you have both a 3.8 and 3.12 python interpreter installed in order to verify changes across different versions of python.
49
64
 
50
65
  ### Dependencies
51
66
 
@@ -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
@@ -1,6 +1,5 @@
1
1
  import logging
2
2
  import time
3
- from os.path import join
4
3
  from typing import Dict, List, Optional
5
4
 
6
5
  import requests
@@ -16,6 +15,7 @@ from devcycle_python_sdk.models.event import DevCycleEvent
16
15
  from devcycle_python_sdk.models.feature import Feature
17
16
  from devcycle_python_sdk.models.user import DevCycleUser
18
17
  from devcycle_python_sdk.models.variable import Variable
18
+ from devcycle_python_sdk.util.strings import slash_join
19
19
 
20
20
  logger = logging.getLogger(__name__)
21
21
 
@@ -33,7 +33,7 @@ class BucketingAPIClient:
33
33
  self.session.max_redirects = 0
34
34
 
35
35
  def _url(self, *path_args: str) -> str:
36
- return join(self.options.bucketing_api_uri, "v1", *path_args)
36
+ return slash_join(self.options.bucketing_api_uri, "v1", *path_args)
37
37
 
38
38
  def request(self, method: str, url: str, **kwargs) -> dict:
39
39
  retries_remaining = self.options.request_retries + 1
@@ -1,9 +1,8 @@
1
1
  import logging
2
2
  import time
3
3
  from http import HTTPStatus
4
- from os.path import join
5
4
  from typing import Optional, Tuple
6
-
5
+ import email.utils
7
6
  import requests
8
7
 
9
8
  from devcycle_python_sdk.api.backoff import exponential_backoff
@@ -13,6 +12,7 @@ from devcycle_python_sdk.exceptions import (
13
12
  NotFoundError,
14
13
  APIClientUnauthorizedError,
15
14
  )
15
+ from devcycle_python_sdk.util.strings import slash_join
16
16
 
17
17
  logger = logging.getLogger(__name__)
18
18
 
@@ -29,20 +29,22 @@ class ConfigAPIClient:
29
29
  self.session.max_redirects = 0
30
30
  self.max_config_retries = 2
31
31
  self.config_file_url = (
32
- join(self.options.config_cdn_uri, "config", "v1", "server", self.sdk_key)
32
+ slash_join(
33
+ self.options.config_cdn_uri, "config", "v1", "server", self.sdk_key
34
+ )
33
35
  + ".json"
34
36
  )
35
37
 
36
38
  def get_config(
37
- self, config_etag: Optional[str] = None
38
- ) -> Tuple[Optional[dict], Optional[str]]:
39
+ self, config_etag: Optional[str] = None, last_modified: Optional[str] = None
40
+ ) -> Tuple[Optional[dict], Optional[str], Optional[str]]:
39
41
  """
40
42
  Get the config from the server. If the config_etag is provided, the server will only return the config if it
41
43
  has changed since the last request. If the config hasn't changed, the server will return a 304 Not Modified
42
44
  response.
43
45
 
44
46
  :param config_etag: The etag of the last config request
45
-
47
+ :param last_modified: Last modified RFC1123 Timestamp of the stored config
46
48
  :return: A tuple containing the config and the etag of the config. If the config hasn't changed since the last
47
49
  request, the config will be None and the etag will be the same as the last request.
48
50
  """
@@ -54,6 +56,8 @@ class ConfigAPIClient:
54
56
  headers = {}
55
57
  if config_etag:
56
58
  headers["If-None-Match"] = config_etag
59
+ if last_modified:
60
+ headers["If-Modified-Since"] = last_modified
57
61
 
58
62
  attempts = 1
59
63
  while retries_remaining > 0:
@@ -72,7 +76,7 @@ class ConfigAPIClient:
72
76
  elif res.status_code == HTTPStatus.NOT_MODIFIED:
73
77
  # the config hasn't changed since the last request
74
78
  # don't return anything
75
- return None, config_etag
79
+ return None, config_etag, last_modified
76
80
  elif res.status_code == HTTPStatus.NOT_FOUND:
77
81
  # Not a retryable error
78
82
  raise NotFoundError(url)
@@ -111,6 +115,16 @@ class ConfigAPIClient:
111
115
  raise APIClientError(message="Retries exceeded", cause=request_error)
112
116
 
113
117
  new_etag = res.headers.get("ETag", None)
118
+ new_lastmodified = res.headers.get("Last-Modified", None)
119
+
120
+ if last_modified and new_lastmodified:
121
+ stored_lm = email.utils.parsedate_to_datetime(last_modified)
122
+ response_lm = email.utils.parsedate_to_datetime(new_lastmodified)
123
+ if stored_lm > response_lm:
124
+ logger.warning(
125
+ "Request returned a last modified header older than the current stored timestamp. not saving config"
126
+ )
127
+ return None, None, None
114
128
 
115
129
  data: dict = res.json()
116
- return data, new_etag
130
+ return data, new_etag, new_lastmodified
@@ -1,7 +1,6 @@
1
1
  import json
2
2
  import logging
3
3
  import time
4
- from os.path import join
5
4
  from typing import Optional, List
6
5
 
7
6
  import requests
@@ -14,6 +13,7 @@ from devcycle_python_sdk.exceptions import (
14
13
  APIClientUnauthorizedError,
15
14
  )
16
15
  from devcycle_python_sdk.models.event import UserEventsBatchRecord
16
+ from devcycle_python_sdk.util.strings import slash_join
17
17
 
18
18
  logger = logging.getLogger(__name__)
19
19
 
@@ -29,7 +29,7 @@ class EventAPIClient:
29
29
  }
30
30
  self.session.max_redirects = 0
31
31
  self.max_batch_retries = 0 # we don't retry events batches
32
- self.batch_url = join(self.options.events_api_uri, "v1/events/batch")
32
+ self.batch_url = slash_join(self.options.events_api_uri, "v1/events/batch")
33
33
 
34
34
  def publish_events(self, batch: List[UserEventsBatchRecord]) -> str:
35
35
  """
@@ -105,7 +105,7 @@ class LocalBucketing:
105
105
 
106
106
  def __console_log_func(message_ptr) -> None:
107
107
  message: str = self._read_assembly_script_string(message_ptr)
108
- logger.warning(f"WASM console: {message!r}")
108
+ logger.warning(f"DevCycle: WASM console: {message!r}")
109
109
 
110
110
  wasm_linker.define_func(
111
111
  "env", "console.log", FuncType([ValType.i32()], []), __console_log_func
@@ -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
@@ -73,15 +90,15 @@ class DevCycleCloudClient:
73
90
  try:
74
91
  variable = self.bucketing_api.variable(key, user)
75
92
  except CloudClientUnauthorizedError as e:
76
- logger.warning("DevCycle SDK key is invalid, unable to make cloud request")
93
+ logger.warning("DevCycle: SDK key is invalid, unable to make cloud request")
77
94
  raise e
78
95
  except NotFoundError:
79
- logger.warning(f"Variable not found: {key}")
96
+ logger.warning(f"DevCycle: Variable not found: {key}")
80
97
  return Variable.create_default_variable(
81
98
  key=key, default_value=default_value
82
99
  )
83
100
  except Exception as e:
84
- logger.error(f"Error evaluating variable: {e}")
101
+ logger.error(f"DevCycle: Error evaluating variable: {e}")
85
102
  return Variable.create_default_variable(
86
103
  key=key, default_value=default_value
87
104
  )
@@ -91,7 +108,7 @@ class DevCycleCloudClient:
91
108
  # Allow default value to be a subclass of the same type as the variable
92
109
  if not isinstance(default_value, type(variable.value)):
93
110
  logger.warning(
94
- f"Variable {key} is type {type(variable.value)}, but default value is type {type(default_value)}",
111
+ f"DevCycle: Variable {key} is type {type(variable.value)}, but default value is type {type(default_value)}",
95
112
  )
96
113
  return Variable.create_default_variable(
97
114
  key=key, default_value=default_value
@@ -112,10 +129,10 @@ class DevCycleCloudClient:
112
129
  try:
113
130
  variable_map = self.bucketing_api.variables(user)
114
131
  except CloudClientUnauthorizedError as e:
115
- logger.warning("SDK key is invalid, unable to make cloud request")
132
+ logger.warning("DevCycle: SDK key is invalid, unable to make cloud request")
116
133
  raise e
117
134
  except Exception as e:
118
- logger.error(f"Error retrieving all features for a user: {e}")
135
+ logger.error(f"DevCycle: Error retrieving all features for a user: {e}")
119
136
 
120
137
  return variable_map
121
138
 
@@ -132,10 +149,10 @@ class DevCycleCloudClient:
132
149
  try:
133
150
  feature_map = self.bucketing_api.features(user)
134
151
  except CloudClientUnauthorizedError as e:
135
- logger.warning("SDK key is invalid, unable to make cloud request")
152
+ logger.warning("DevCycle: SDK key is invalid, unable to make cloud request")
136
153
  raise e
137
154
  except Exception as e:
138
- logger.error(f"Error retrieving all features for a user: {e}")
155
+ logger.error(f"DevCycle: Error retrieving all features for a user: {e}")
139
156
 
140
157
  return feature_map
141
158
 
@@ -160,10 +177,10 @@ class DevCycleCloudClient:
160
177
  try:
161
178
  self.bucketing_api.track(user, events)
162
179
  except CloudClientUnauthorizedError as e:
163
- logger.warning("SDK key is invalid, unable to make cloud request")
180
+ logger.warning("DevCycle: SDK key is invalid, unable to make cloud request")
164
181
  raise e
165
182
  except Exception as e:
166
- logger.error(f"Error tracking event: {e}")
183
+ logger.error(f"DevCycle: Error tracking event: {e}")
167
184
 
168
185
 
169
186
  def _validate_sdk_key(sdk_key: str) -> None:
@@ -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
 
@@ -57,7 +71,9 @@ class DevCycleLocalClient:
57
71
  are not permitted
58
72
  """
59
73
  if not self.is_initialized():
60
- logger.debug("set_client_custom_data called before client has initialized")
74
+ logger.debug(
75
+ "DevCycle: set_client_custom_data called before client has initialized"
76
+ )
61
77
  return
62
78
 
63
79
  if custom_data:
@@ -65,7 +81,7 @@ class DevCycleLocalClient:
65
81
  custom_data_json = json.dumps(custom_data)
66
82
  self.local_bucketing.set_client_custom_data(custom_data_json)
67
83
  except Exception as e:
68
- logger.error("Error setting custom data: " + str(e))
84
+ logger.error("DevCycle: Error setting custom data: " + str(e))
69
85
 
70
86
  def variable_value(self, user: DevCycleUser, key: str, default_value: Any) -> Any:
71
87
  """
@@ -92,7 +108,7 @@ class DevCycleLocalClient:
92
108
  raise ValueError("Missing parameter: defaultValue")
93
109
 
94
110
  if not self.is_initialized():
95
- logger.debug("variable called before client has initialized")
111
+ logger.debug("DevCycle: variable called before client has initialized")
96
112
  try:
97
113
  self.event_queue_manager.queue_aggregate_event(
98
114
  event=DevCycleEvent(
@@ -102,7 +118,7 @@ class DevCycleLocalClient:
102
118
  )
103
119
  except Exception as e:
104
120
  logger.warning(
105
- f"Unable to track AggVariableDefaulted event for Variable {key}: {e}"
121
+ f"DevCycle: Unable to track AggVariableDefaulted event for Variable {key}: {e}"
106
122
  )
107
123
  return Variable.create_default_variable(key, default_value)
108
124
 
@@ -113,9 +129,9 @@ class DevCycleLocalClient:
113
129
  if variable:
114
130
  return variable
115
131
  except VariableTypeMismatchError:
116
- logger.debug("Variable type mismatch, returning default value")
132
+ logger.debug("DevCycle: Variable type mismatch, returning default value")
117
133
  except Exception as e:
118
- logger.warning(f"Error retrieving variable for user: {e}")
134
+ logger.warning(f"DevCycle: Error retrieving variable for user: {e}")
119
135
 
120
136
  return Variable.create_default_variable(key, default_value)
121
137
 
@@ -137,7 +153,9 @@ class DevCycleLocalClient:
137
153
  _validate_user(user)
138
154
 
139
155
  if not self.is_initialized():
140
- logger.warning("all_variables called before client has initialized")
156
+ logger.warning(
157
+ "DevCycle: all_variables called before client has initialized"
158
+ )
141
159
  return {}
142
160
 
143
161
  variable_map: Dict[str, Variable] = {}
@@ -145,7 +163,9 @@ class DevCycleLocalClient:
145
163
  try:
146
164
  return self.local_bucketing.generate_bucketed_config(user).variables
147
165
  except Exception as e:
148
- logger.exception(f"Error retrieving all variables for a user: {e}")
166
+ logger.exception(
167
+ f"DevCycle: Error retrieving all variables for a user: {e}"
168
+ )
149
169
  return {}
150
170
 
151
171
  return variable_map
@@ -159,14 +179,16 @@ class DevCycleLocalClient:
159
179
  _validate_user(user)
160
180
 
161
181
  if not self.is_initialized():
162
- logger.warning("all_features called before client has initialized")
182
+ logger.warning(
183
+ "DevCycle: all_features called before client has initialized"
184
+ )
163
185
  return {}
164
186
 
165
187
  feature_map: Dict[str, Feature] = {}
166
188
  try:
167
189
  return self.local_bucketing.generate_bucketed_config(user).features
168
190
  except Exception as e:
169
- logger.exception(f"Error retrieving all features for a user: {e}")
191
+ logger.exception(f"DevCycle: Error retrieving all features for a user: {e}")
170
192
 
171
193
  return feature_map
172
194
 
@@ -186,13 +208,13 @@ class DevCycleLocalClient:
186
208
  raise ValueError("Missing parameter: type")
187
209
 
188
210
  if not self.is_initialized():
189
- logger.debug("track called before client has initialized")
211
+ logger.debug("DevCycle: track called before client has initialized")
190
212
  return
191
213
 
192
214
  try:
193
215
  self.event_queue_manager.queue_event(user, user_event)
194
216
  except Exception as e:
195
- logger.error(f"Error tracking event: {e}")
217
+ logger.error(f"DevCycle: Error tracking event: {e}")
196
218
 
197
219
  def close(self) -> None:
198
220
  """
@@ -30,6 +30,7 @@ class EnvironmentConfigManager(threading.Thread):
30
30
 
31
31
  self._config: Optional[dict] = None
32
32
  self._config_etag: Optional[str] = None
33
+ self._config_lastmodified: Optional[str] = None
33
34
 
34
35
  self._config_api_client = ConfigAPIClient(self._sdk_key, self._options)
35
36
 
@@ -42,17 +43,20 @@ class EnvironmentConfigManager(threading.Thread):
42
43
 
43
44
  def _get_config(self):
44
45
  try:
45
- new_config, new_etag = self._config_api_client.get_config(
46
- config_etag=self._config_etag
46
+ new_config, new_etag, new_lastmodified = self._config_api_client.get_config(
47
+ config_etag=self._config_etag, last_modified=self._config_lastmodified
47
48
  )
48
49
 
50
+ # Abort early if the last modified is before the sent one.
51
+ if new_config is None and new_etag is None and new_lastmodified is None:
52
+ return
49
53
  if new_config is None and new_etag == self._config_etag:
50
54
  # api not returning data and the etag is the same
51
55
  # no change to the config since last request
52
56
  return
53
57
  elif new_config is None:
54
58
  logger.warning(
55
- "Config CDN fetch returned no data with a different etag"
59
+ "DevCycle: Config CDN fetch returned no data with a different etag"
56
60
  )
57
61
  return
58
62
 
@@ -60,6 +64,7 @@ class EnvironmentConfigManager(threading.Thread):
60
64
 
61
65
  self._config = new_config
62
66
  self._config_etag = new_etag
67
+ self._config_lastmodified = new_lastmodified
63
68
 
64
69
  json_config = json.dumps(self._config)
65
70
  self._local_bucketing.store_config(json_config)
@@ -73,12 +78,14 @@ class EnvironmentConfigManager(threading.Thread):
73
78
  except Exception as e:
74
79
  # consume any error
75
80
  logger.warning(
76
- f"Error received from on_client_initialized callback: {str(e)}"
81
+ f"DevCycle: Error received from on_client_initialized callback: {str(e)}"
77
82
  )
78
83
  except CloudClientError as e:
79
- logger.warning(f"Config fetch failed. Status: {str(e)}")
84
+ logger.warning(f"DevCycle: Config fetch failed. Status: {str(e)}")
80
85
  except CloudClientUnauthorizedError:
81
- logger.error("Unauthorized to get config. Aborting config polling.")
86
+ logger.error(
87
+ "DevCycle: Unauthorized to get config. Aborting config polling."
88
+ )
82
89
  self._polling_enabled = False
83
90
 
84
91
  def run(self):
@@ -88,7 +95,9 @@ class EnvironmentConfigManager(threading.Thread):
88
95
  except Exception as e:
89
96
  if self._polling_enabled:
90
97
  # Only log a warning if we're still polling
91
- logger.warning(f"Error polling for config changes: {str(e)}")
98
+ logger.warning(
99
+ f"DevCycle: Error polling for config changes: {str(e)}"
100
+ )
92
101
  time.sleep(self._options.config_polling_interval_ms / 1000.0)
93
102
 
94
103
  def close(self):