spotify-confidence-sdk 0.2.1__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.
confidence/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ try:
2
+ from ._version import version as __version__
3
+ from ._version import version_tuple
4
+ except ImportError:
5
+ __version__ = "unknown version"
6
+ version_tuple = (0, 0, 0, "unknown version")
confidence/_version.py ADDED
@@ -0,0 +1,16 @@
1
+ # file generated by setuptools_scm
2
+ # don't change, don't track in version control
3
+ TYPE_CHECKING = False
4
+ if TYPE_CHECKING:
5
+ from typing import Tuple, Union
6
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
7
+ else:
8
+ VERSION_TUPLE = object
9
+
10
+ version: str
11
+ __version__: str
12
+ __version_tuple__: VERSION_TUPLE
13
+ version_tuple: VERSION_TUPLE
14
+
15
+ __version__ = version = '0.2.1'
16
+ __version_tuple__ = version_tuple = (0, 2, 1)
@@ -0,0 +1,286 @@
1
+ import asyncio
2
+ import dataclasses
3
+ from datetime import datetime
4
+ from enum import Enum
5
+ import logging
6
+ from typing import (
7
+ Any,
8
+ Dict,
9
+ List,
10
+ Optional,
11
+ Type,
12
+ Union,
13
+ get_args,
14
+ get_origin,
15
+ )
16
+
17
+ import requests
18
+ from typing_extensions import TypeGuard
19
+
20
+ from confidence import __version__
21
+ from confidence.errors import (
22
+ FlagNotFoundError,
23
+ ParseError,
24
+ TypeMismatchError,
25
+ )
26
+ from .flag_types import FlagResolutionDetails, Reason
27
+ from .names import FlagName, VariantName
28
+
29
+ EU_RESOLVE_API_ENDPOINT = "https://resolver.eu.confidence.dev/v1"
30
+ US_RESOLVE_API_ENDPOINT = "https://resolver.us.confidence.dev/v1"
31
+ GLOBAL_RESOLVE_API_ENDPOINT = "https://resolver.confidence.dev/v1"
32
+
33
+ Primitive = Union[str, int, float, bool, None]
34
+ FieldType = Union[Primitive, List[Primitive], List["Object"], "Object"]
35
+ Object = Dict[str, FieldType]
36
+
37
+
38
+ def is_primitive(field_type: Type[Any]) -> TypeGuard[Type[Primitive]]:
39
+ return field_type in get_args(Primitive)
40
+
41
+
42
+ def primitive_matches(value: FieldType, value_type: Type[Primitive]) -> bool:
43
+ return (
44
+ value_type is None
45
+ or (value_type is int and isinstance(value, int))
46
+ or (value_type is float and isinstance(value, float))
47
+ or (value_type is str and isinstance(value, str))
48
+ or (value_type is bool and isinstance(value, bool))
49
+ )
50
+
51
+
52
+ class Region(Enum):
53
+ def endpoint(self) -> str:
54
+ return self.value
55
+
56
+ EU = EU_RESOLVE_API_ENDPOINT
57
+ US = US_RESOLVE_API_ENDPOINT
58
+ GLOBAL = GLOBAL_RESOLVE_API_ENDPOINT
59
+
60
+
61
+ @dataclasses.dataclass
62
+ class ResolveResult(object):
63
+ value: Optional[Object]
64
+ variant: Optional[str]
65
+ token: str
66
+
67
+
68
+ class Confidence:
69
+ context: Dict[str, FieldType] = {}
70
+
71
+ def put_context(self, key: str, value: FieldType) -> None:
72
+ self.context[key] = value
73
+
74
+ def with_context(self, context: Dict[str, FieldType]) -> "Confidence":
75
+ new_confidence = Confidence(
76
+ self._client_secret, self._region, self._apply_on_resolve
77
+ )
78
+ new_confidence.context = {**self.context, **context}
79
+ return new_confidence
80
+
81
+ def __init__(
82
+ self,
83
+ client_secret: str,
84
+ region: Region = Region.GLOBAL,
85
+ apply_on_resolve: bool = True,
86
+ logger: logging.Logger = logging.getLogger("confidence_logger"),
87
+ ):
88
+ self._client_secret = client_secret
89
+ self._region = region
90
+ self._api_endpoint = region.endpoint()
91
+ self._apply_on_resolve = apply_on_resolve
92
+ self.logger = logger
93
+ self._setup_logger(logger)
94
+
95
+ def resolve_boolean_details(
96
+ self, flag_key: str, default_value: bool
97
+ ) -> FlagResolutionDetails[bool]:
98
+ return self._evaluate(flag_key, bool, default_value, self.context)
99
+
100
+ def resolve_float_details(
101
+ self, flag_key: str, default_value: float
102
+ ) -> FlagResolutionDetails[float]:
103
+ return self._evaluate(flag_key, float, default_value, self.context)
104
+
105
+ def resolve_integer_details(
106
+ self, flag_key: str, default_value: int
107
+ ) -> FlagResolutionDetails[int]:
108
+ return self._evaluate(flag_key, int, default_value, self.context)
109
+
110
+ def resolve_string_details(
111
+ self, flag_key: str, default_value: str
112
+ ) -> FlagResolutionDetails[str]:
113
+ return self._evaluate(flag_key, str, default_value, self.context)
114
+
115
+ def resolve_object_details(
116
+ self, flag_key: str, default_value: Union[Object, List[Primitive]]
117
+ ) -> FlagResolutionDetails[Union[Object, List[Primitive]]]:
118
+ return self._evaluate(flag_key, Object, default_value, self.context)
119
+
120
+ def _setup_logger(self, logger: logging.Logger) -> None:
121
+ if logger is not None:
122
+ logger.setLevel(logging.DEBUG)
123
+ formatter = logging.Formatter(
124
+ "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
125
+ )
126
+ if not logger.hasHandlers():
127
+ ch = logging.StreamHandler()
128
+ ch.setFormatter(formatter)
129
+ logger.addHandler(ch)
130
+
131
+ #
132
+ # --- internals
133
+ #
134
+
135
+ def _evaluate(
136
+ self,
137
+ flag_key: str,
138
+ value_type: Type[FieldType],
139
+ default_value: FieldType,
140
+ context: Dict[str, FieldType],
141
+ ) -> FlagResolutionDetails[Any]:
142
+ if "." in flag_key:
143
+ flag_id, value_path = flag_key.split(".", 1)
144
+ else:
145
+ flag_id = flag_key
146
+ value_path = None
147
+ result = self._resolve(FlagName(flag_id), context)
148
+ if result.variant is None or len(str(result.value)) == 0:
149
+ return FlagResolutionDetails(
150
+ value=default_value,
151
+ reason=Reason.DEFAULT,
152
+ flag_metadata={"flag_key": flag_key},
153
+ )
154
+
155
+ variant_name = VariantName.parse(result.variant)
156
+
157
+ value = self._select(result, value_path, value_type, self.logger)
158
+ if value is None:
159
+ self.logger.debug(
160
+ f"Flag {flag_key} resolved to None. Returning default value."
161
+ )
162
+ value = default_value
163
+
164
+ return FlagResolutionDetails(
165
+ value=value,
166
+ variant=variant_name.variant,
167
+ reason=Reason.TARGETING_MATCH,
168
+ flag_metadata={"flag_key": flag_key},
169
+ )
170
+
171
+ # type-arg: ignore
172
+ def track(self, event_name: str, data: Dict[str, FieldType]) -> None:
173
+ asyncio.create_task(self._send_event(event_name, data))
174
+
175
+ async def track_async(self, event_name: str, data: Dict[str, FieldType]) -> None:
176
+ await self._send_event(event_name, data)
177
+
178
+ async def _send_event(self, event_name: str, data: Dict[str, FieldType]) -> None:
179
+ current_time = datetime.utcnow().isoformat() + "Z"
180
+ request_body = {
181
+ "clientSecret": self._client_secret,
182
+ "sendTime": current_time,
183
+ "events": [
184
+ {
185
+ "eventDefinition": f"eventDefinitions/{event_name}",
186
+ "payload": {"context": {**self.context}, **data},
187
+ "eventTime": current_time,
188
+ }
189
+ ],
190
+ "sdk": {"id": "SDK_ID_PYTHON_CONFIDENCE", "version": __version__},
191
+ }
192
+
193
+ event_url = "https://events.confidence.dev/v1/events:publish"
194
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
195
+ response = requests.post(event_url, json=request_body, headers=headers)
196
+ response.raise_for_status()
197
+ json = response.json()
198
+
199
+ json_errors = json.get("errors")
200
+ if json_errors:
201
+ self.logger.warn("events emitted with errors:")
202
+ for error in json_errors:
203
+ self.logger.warn(error)
204
+
205
+ def _resolve(
206
+ self, flag_name: FlagName, context: Dict[str, FieldType]
207
+ ) -> ResolveResult:
208
+ request_body = {
209
+ "clientSecret": self._client_secret,
210
+ "evaluationContext": context,
211
+ "apply": self._apply_on_resolve,
212
+ "flags": [str(flag_name)],
213
+ "sdk": {"id": "SDK_ID_PYTHON_CONFIDENCE", "version": __version__},
214
+ }
215
+
216
+ resolve_url = f"{self._api_endpoint}/flags:resolve"
217
+ response = requests.post(resolve_url, json=request_body)
218
+ if response.status_code == 404:
219
+ self.logger.error(f"Flag {flag_name} not found")
220
+ raise FlagNotFoundError()
221
+
222
+ response.raise_for_status()
223
+
224
+ response_body = response.json()
225
+
226
+ resolved_flags = response_body["resolvedFlags"]
227
+ token = response_body["resolveToken"]
228
+
229
+ if len(resolved_flags) == 0:
230
+ self.logger.info(f"Flag {flag_name} not found")
231
+ return ResolveResult(None, None, token)
232
+
233
+ resolved_flag = resolved_flags[0]
234
+ variant = resolved_flag.get("variant")
235
+ return ResolveResult(
236
+ resolved_flag.get("value"), None if variant == "" else variant, token
237
+ )
238
+
239
+ @staticmethod
240
+ def _select(
241
+ result: ResolveResult,
242
+ value_path: Optional[str],
243
+ value_type: Type[FieldType],
244
+ logger: logging.Logger,
245
+ ) -> FieldType:
246
+ value: FieldType = result.value
247
+
248
+ if value_path is not None:
249
+ keys = value_path.split(".")
250
+ for key in keys:
251
+ if not isinstance(value, dict):
252
+ logger.debug(f"Value {value} is not a dict. Returning None.")
253
+ raise ParseError()
254
+
255
+ if key not in value:
256
+ logger.debug(
257
+ f"Key {key} not found in value {value}. Returning None."
258
+ )
259
+ raise ParseError()
260
+
261
+ value = value.get(key)
262
+
263
+ # skip type checking if the value was not specified
264
+ if value is None:
265
+ return None
266
+
267
+ if not Confidence.type_matches(value, value_type):
268
+ logger.debug(
269
+ f"Type of value {value} did not match expected type {value_type}."
270
+ )
271
+ raise TypeMismatchError("type of value did not match excepted type")
272
+
273
+ return value
274
+
275
+ @staticmethod
276
+ def type_matches(value: FieldType, value_type: Type[FieldType]) -> bool:
277
+ origin = get_origin(value_type)
278
+
279
+ if is_primitive(value_type):
280
+ return primitive_matches(value, value_type)
281
+ elif origin is list:
282
+ return isinstance(value, list)
283
+ elif origin is dict:
284
+ return isinstance(value, dict)
285
+
286
+ return False
confidence/errors.py ADDED
@@ -0,0 +1,127 @@
1
+ import typing
2
+ from enum import Enum
3
+
4
+
5
+ class ErrorCode(Enum):
6
+ NOT_READY = "NOT_READY"
7
+ FLAG_NOT_FOUND = "FLAG_NOT_FOUND"
8
+ PARSE_ERROR = "PARSE_ERROR"
9
+ TYPE_MISMATCH = "TYPE_MISMATCH"
10
+ TARGETING_KEY_MISSING = "TARGETING_KEY_MISSING"
11
+ INVALID_CONTEXT = "INVALID_CONTEXT"
12
+ GENERAL = "GENERAL"
13
+
14
+
15
+ class ConfidenceError(Exception):
16
+ """
17
+ A generic confidence exception, this exception should not be raised. Instead
18
+ the more specific exceptions extending this one should be used.
19
+ """
20
+
21
+ def __init__(
22
+ self, error_code: ErrorCode, error_message: typing.Optional[str] = None
23
+ ):
24
+ """
25
+ Constructor for the generic ConfidenceError.
26
+ @param error_message: an optional string message representing why the
27
+ error has been raised
28
+ @param error_code: the ErrorCode string enum value for the type of error
29
+ """
30
+ self.error_message = error_message
31
+ self.error_code = error_code
32
+
33
+
34
+ class FlagNotFoundError(ConfidenceError):
35
+ """
36
+ This exception should be raised when the provider cannot find a flag with the
37
+ key provided by the user.
38
+ """
39
+
40
+ def __init__(self, error_message: typing.Optional[str] = None):
41
+ """
42
+ Constructor for the FlagNotFoundError. The error code for
43
+ this type of exception is ErrorCode.FLAG_NOT_FOUND.
44
+ @param error_message: an optional string message representing
45
+ why the error has been raised
46
+ """
47
+ super().__init__(ErrorCode.FLAG_NOT_FOUND, error_message)
48
+
49
+
50
+ class GeneralError(ConfidenceError):
51
+ """
52
+ This exception should be raised when the for an exception within the open
53
+ feature python sdk.
54
+ """
55
+
56
+ def __init__(self, error_message: typing.Optional[str] = None):
57
+ """
58
+ Constructor for the GeneralError. The error code for this type of exception
59
+ is ErrorCode.GENERAL.
60
+ @param error_message: an optional string message representing why the error
61
+ has been raised
62
+ """
63
+ super().__init__(ErrorCode.GENERAL, error_message)
64
+
65
+
66
+ class ParseError(ConfidenceError):
67
+ """
68
+ This exception should be raised when the flag returned by the provider cannot
69
+ be parsed into a FlagEvaluationDetails object.
70
+ """
71
+
72
+ def __init__(self, error_message: typing.Optional[str] = None):
73
+ """
74
+ Constructor for the ParseError. The error code for this type of exception
75
+ is ErrorCode.PARSE_ERROR.
76
+ @param error_message: an optional string message representing why the
77
+ error has been raised
78
+ """
79
+ super().__init__(ErrorCode.PARSE_ERROR, error_message)
80
+
81
+
82
+ class TypeMismatchError(ConfidenceError):
83
+ """
84
+ This exception should be raised when the flag returned by the provider does
85
+ not match the type requested by the user.
86
+ """
87
+
88
+ def __init__(self, error_message: typing.Optional[str] = None):
89
+ """
90
+ Constructor for the TypeMismatchError. The error code for this type of
91
+ exception is ErrorCode.TYPE_MISMATCH.
92
+ @param error_message: an optional string message representing why the
93
+ error has been raised
94
+ """
95
+ super().__init__(ErrorCode.TYPE_MISMATCH, error_message)
96
+
97
+
98
+ class TargetingKeyMissingError(ConfidenceError):
99
+ """
100
+ This exception should be raised when the provider requires a targeting key
101
+ but one was not provided in the evaluation context.
102
+ """
103
+
104
+ def __init__(self, error_message: typing.Optional[str] = None):
105
+ """
106
+ Constructor for the TargetingKeyMissingError. The error code for this type of
107
+ exception is ErrorCode.TARGETING_KEY_MISSING.
108
+ @param error_message: a string message representing why the error has been
109
+ raised
110
+ """
111
+ super().__init__(ErrorCode.TARGETING_KEY_MISSING, error_message)
112
+
113
+
114
+ class InvalidContextError(ConfidenceError):
115
+ """
116
+ This exception should be raised when the evaluation context does not meet provider
117
+ requirements.
118
+ """
119
+
120
+ def __init__(self, error_message: typing.Optional[str]):
121
+ """
122
+ Constructor for the InvalidContextError. The error code for this type of
123
+ exception is ErrorCode.INVALID_CONTEXT.
124
+ @param error_message: a string message representing why the error has been
125
+ raised
126
+ """
127
+ super().__init__(ErrorCode.INVALID_CONTEXT, error_message)
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import typing
5
+ from dataclasses import dataclass, field
6
+
7
+ from confidence.errors import ErrorCode
8
+
9
+ if sys.version_info >= (3, 11):
10
+ # re-export needed for type checking
11
+ from enum import StrEnum as StrEnum # noqa: PLC0414
12
+ else:
13
+ from enum import Enum
14
+
15
+ class StrEnum(str, Enum):
16
+ """
17
+ Backport StrEnum for Python <3.11
18
+ """
19
+
20
+ pass
21
+
22
+
23
+ class FlagType(StrEnum):
24
+ BOOLEAN = "BOOLEAN"
25
+ STRING = "STRING"
26
+ OBJECT = "OBJECT"
27
+ FLOAT = "FLOAT"
28
+ INTEGER = "INTEGER"
29
+
30
+
31
+ class Reason(StrEnum):
32
+ CACHED = "CACHED"
33
+ DEFAULT = "DEFAULT"
34
+ DISABLED = "DISABLED"
35
+ ERROR = "ERROR"
36
+ STATIC = "STATIC"
37
+ SPLIT = "SPLIT"
38
+ TARGETING_MATCH = "TARGETING_MATCH"
39
+ UNKNOWN = "UNKNOWN"
40
+
41
+
42
+ FlagMetadata = typing.Mapping[str, typing.Any]
43
+
44
+ T_co = typing.TypeVar("T_co", covariant=True)
45
+
46
+
47
+ @dataclass
48
+ class FlagEvaluationDetails(typing.Generic[T_co]):
49
+ flag_key: str
50
+ value: T_co
51
+ variant: typing.Optional[str] = None
52
+ flag_metadata: FlagMetadata = field(default_factory=dict)
53
+ reason: typing.Optional[typing.Union[str, Reason]] = None
54
+ error_code: typing.Optional[ErrorCode] = None
55
+ error_message: typing.Optional[str] = None
56
+
57
+
58
+ U_co = typing.TypeVar("U_co", covariant=True)
59
+
60
+
61
+ @dataclass
62
+ class FlagResolutionDetails(typing.Generic[U_co]):
63
+ value: U_co
64
+ error_code: typing.Optional[ErrorCode] = None
65
+ error_message: typing.Optional[str] = None
66
+ reason: typing.Optional[typing.Union[str, Reason]] = None
67
+ variant: typing.Optional[str] = None
68
+ flag_metadata: FlagMetadata = field(default_factory=dict)
confidence/names.py ADDED
@@ -0,0 +1,29 @@
1
+ import dataclasses
2
+
3
+
4
+ @dataclasses.dataclass
5
+ class FlagName(object):
6
+ flag: str
7
+
8
+ @classmethod
9
+ def parse(cls, resource_name: str) -> "FlagName":
10
+ components = resource_name.split("/", 2)
11
+ if components[0] != "flags":
12
+ raise ValueError("name error")
13
+ return cls(components[1])
14
+
15
+ def __str__(self) -> str:
16
+ return f"flags/{self.flag}"
17
+
18
+
19
+ @dataclasses.dataclass
20
+ class VariantName(object):
21
+ flag: str
22
+ variant: str
23
+
24
+ @classmethod
25
+ def parse(cls, resource_name: str) -> "VariantName":
26
+ components = resource_name.split("/")
27
+ if components[0] != "flags" or components[2] != "variants":
28
+ raise ValueError("name error")
29
+ return cls(components[1], components[3])