mixpanel-openfeature 0.1.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.
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import typing
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from typing import Optional, Union
|
|
7
|
+
|
|
8
|
+
from openfeature.evaluation_context import EvaluationContext
|
|
9
|
+
from openfeature.exception import ErrorCode
|
|
10
|
+
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
|
|
11
|
+
from openfeature.provider import AbstractProvider, Metadata
|
|
12
|
+
|
|
13
|
+
from mixpanel import Mixpanel
|
|
14
|
+
from mixpanel.flags.types import LocalFlagsConfig, RemoteFlagsConfig, SelectedVariant
|
|
15
|
+
|
|
16
|
+
FlagValueType = Union[bool, str, int, float, list, dict, None]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MixpanelProvider(AbstractProvider):
|
|
20
|
+
"""An OpenFeature provider backed by a Mixpanel feature flags provider."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
flags_provider: typing.Any,
|
|
25
|
+
mixpanel_instance: Optional[Mixpanel] = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
super().__init__()
|
|
28
|
+
self._flags_provider = flags_provider
|
|
29
|
+
self._mixpanel = mixpanel_instance
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_local_config(
|
|
33
|
+
cls, token: str, config: LocalFlagsConfig
|
|
34
|
+
) -> MixpanelProvider:
|
|
35
|
+
"""Create a MixpanelProvider backed by a local flags provider.
|
|
36
|
+
|
|
37
|
+
:param str token: your project's Mixpanel token
|
|
38
|
+
:param LocalFlagsConfig config: configuration for local feature flags
|
|
39
|
+
"""
|
|
40
|
+
mp = Mixpanel(token, local_flags_config=config)
|
|
41
|
+
local_flags = mp.local_flags
|
|
42
|
+
local_flags.start_polling_for_definitions()
|
|
43
|
+
return cls(local_flags, mixpanel_instance=mp)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_remote_config(
|
|
47
|
+
cls, token: str, config: RemoteFlagsConfig
|
|
48
|
+
) -> MixpanelProvider:
|
|
49
|
+
"""Create a MixpanelProvider backed by a remote flags provider.
|
|
50
|
+
|
|
51
|
+
:param str token: your project's Mixpanel token
|
|
52
|
+
:param RemoteFlagsConfig config: configuration for remote feature flags
|
|
53
|
+
"""
|
|
54
|
+
mp = Mixpanel(token, remote_flags_config=config)
|
|
55
|
+
remote_flags = mp.remote_flags
|
|
56
|
+
return cls(remote_flags, mixpanel_instance=mp)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def mixpanel(self) -> Optional[Mixpanel]:
|
|
60
|
+
"""The Mixpanel instance used by this provider, if created via a class method."""
|
|
61
|
+
return self._mixpanel
|
|
62
|
+
|
|
63
|
+
def get_metadata(self) -> Metadata:
|
|
64
|
+
return Metadata(name="mixpanel-provider")
|
|
65
|
+
|
|
66
|
+
def shutdown(self) -> None:
|
|
67
|
+
self._flags_provider.shutdown()
|
|
68
|
+
|
|
69
|
+
def resolve_boolean_details(
|
|
70
|
+
self,
|
|
71
|
+
flag_key: str,
|
|
72
|
+
default_value: bool,
|
|
73
|
+
evaluation_context: typing.Optional[EvaluationContext] = None,
|
|
74
|
+
) -> FlagResolutionDetails[bool]:
|
|
75
|
+
return self._resolve(flag_key, default_value, bool, evaluation_context)
|
|
76
|
+
|
|
77
|
+
def resolve_string_details(
|
|
78
|
+
self,
|
|
79
|
+
flag_key: str,
|
|
80
|
+
default_value: str,
|
|
81
|
+
evaluation_context: typing.Optional[EvaluationContext] = None,
|
|
82
|
+
) -> FlagResolutionDetails[str]:
|
|
83
|
+
return self._resolve(flag_key, default_value, str, evaluation_context)
|
|
84
|
+
|
|
85
|
+
def resolve_integer_details(
|
|
86
|
+
self,
|
|
87
|
+
flag_key: str,
|
|
88
|
+
default_value: int,
|
|
89
|
+
evaluation_context: typing.Optional[EvaluationContext] = None,
|
|
90
|
+
) -> FlagResolutionDetails[int]:
|
|
91
|
+
return self._resolve(flag_key, default_value, int, evaluation_context)
|
|
92
|
+
|
|
93
|
+
def resolve_float_details(
|
|
94
|
+
self,
|
|
95
|
+
flag_key: str,
|
|
96
|
+
default_value: float,
|
|
97
|
+
evaluation_context: typing.Optional[EvaluationContext] = None,
|
|
98
|
+
) -> FlagResolutionDetails[float]:
|
|
99
|
+
return self._resolve(flag_key, default_value, float, evaluation_context)
|
|
100
|
+
|
|
101
|
+
def resolve_object_details(
|
|
102
|
+
self,
|
|
103
|
+
flag_key: str,
|
|
104
|
+
default_value: Union[Sequence[FlagValueType], Mapping[str, FlagValueType]],
|
|
105
|
+
evaluation_context: typing.Optional[EvaluationContext] = None,
|
|
106
|
+
) -> FlagResolutionDetails[
|
|
107
|
+
Union[Sequence[FlagValueType], Mapping[str, FlagValueType]]
|
|
108
|
+
]:
|
|
109
|
+
return self._resolve(flag_key, default_value, None, evaluation_context)
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def _unwrap_value(value: typing.Any) -> typing.Any:
|
|
113
|
+
if isinstance(value, dict):
|
|
114
|
+
return {k: MixpanelProvider._unwrap_value(v) for k, v in value.items()}
|
|
115
|
+
if isinstance(value, list):
|
|
116
|
+
return [MixpanelProvider._unwrap_value(item) for item in value]
|
|
117
|
+
if isinstance(value, float) and value.is_integer():
|
|
118
|
+
return int(value)
|
|
119
|
+
return value
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def _build_user_context(
|
|
123
|
+
evaluation_context: typing.Optional[EvaluationContext],
|
|
124
|
+
) -> dict:
|
|
125
|
+
user_context: dict = {}
|
|
126
|
+
if evaluation_context is not None:
|
|
127
|
+
if evaluation_context.attributes:
|
|
128
|
+
for k, v in evaluation_context.attributes.items():
|
|
129
|
+
user_context[k] = MixpanelProvider._unwrap_value(v)
|
|
130
|
+
if evaluation_context.targeting_key:
|
|
131
|
+
user_context["targetingKey"] = evaluation_context.targeting_key
|
|
132
|
+
return user_context
|
|
133
|
+
|
|
134
|
+
def _resolve(
|
|
135
|
+
self,
|
|
136
|
+
flag_key: str,
|
|
137
|
+
default_value: typing.Any,
|
|
138
|
+
expected_type: typing.Optional[type],
|
|
139
|
+
evaluation_context: typing.Optional[EvaluationContext] = None,
|
|
140
|
+
) -> FlagResolutionDetails:
|
|
141
|
+
if not self._are_flags_ready():
|
|
142
|
+
return FlagResolutionDetails(
|
|
143
|
+
value=default_value,
|
|
144
|
+
error_code=ErrorCode.PROVIDER_NOT_READY,
|
|
145
|
+
reason=Reason.ERROR,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
fallback = SelectedVariant(variant_value=default_value)
|
|
149
|
+
user_context = self._build_user_context(evaluation_context)
|
|
150
|
+
try:
|
|
151
|
+
result = self._flags_provider.get_variant(flag_key, fallback, user_context)
|
|
152
|
+
except Exception:
|
|
153
|
+
return FlagResolutionDetails(
|
|
154
|
+
value=default_value,
|
|
155
|
+
error_code=ErrorCode.GENERAL,
|
|
156
|
+
reason=Reason.ERROR,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if result is fallback:
|
|
160
|
+
return FlagResolutionDetails(
|
|
161
|
+
value=default_value,
|
|
162
|
+
error_code=ErrorCode.FLAG_NOT_FOUND,
|
|
163
|
+
reason=Reason.DEFAULT,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
value = result.variant_value
|
|
167
|
+
variant_key = result.variant_key
|
|
168
|
+
|
|
169
|
+
if expected_type is None:
|
|
170
|
+
return FlagResolutionDetails(
|
|
171
|
+
value=value, variant=variant_key, reason=Reason.TARGETING_MATCH
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# In Python, bool is a subclass of int, so isinstance(True, int)
|
|
175
|
+
# returns True. Reject bools early when expecting numeric types.
|
|
176
|
+
if expected_type in (int, float) and isinstance(value, bool):
|
|
177
|
+
return FlagResolutionDetails(
|
|
178
|
+
value=default_value,
|
|
179
|
+
error_code=ErrorCode.TYPE_MISMATCH,
|
|
180
|
+
error_message=f"Expected {expected_type.__name__}, got {type(value).__name__}",
|
|
181
|
+
reason=Reason.ERROR,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if expected_type is int and isinstance(value, float):
|
|
185
|
+
if math.isfinite(value) and value == math.floor(value):
|
|
186
|
+
return FlagResolutionDetails(
|
|
187
|
+
value=int(value), variant=variant_key, reason=Reason.TARGETING_MATCH
|
|
188
|
+
)
|
|
189
|
+
return FlagResolutionDetails(
|
|
190
|
+
value=default_value,
|
|
191
|
+
error_code=ErrorCode.TYPE_MISMATCH,
|
|
192
|
+
error_message=f"Expected int, got float (value={value} is not a whole number)",
|
|
193
|
+
reason=Reason.ERROR,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if expected_type is float and isinstance(value, (int, float)):
|
|
197
|
+
return FlagResolutionDetails(
|
|
198
|
+
value=float(value), variant=variant_key, reason=Reason.TARGETING_MATCH
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
if not isinstance(value, expected_type):
|
|
202
|
+
return FlagResolutionDetails(
|
|
203
|
+
value=default_value,
|
|
204
|
+
error_code=ErrorCode.TYPE_MISMATCH,
|
|
205
|
+
error_message=f"Expected {expected_type.__name__}, got {type(value).__name__}",
|
|
206
|
+
reason=Reason.ERROR,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
return FlagResolutionDetails(
|
|
210
|
+
value=value, variant=variant_key, reason=Reason.TARGETING_MATCH
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def _are_flags_ready(self) -> bool:
|
|
214
|
+
if hasattr(self._flags_provider, "are_flags_ready"):
|
|
215
|
+
return self._flags_provider.are_flags_ready()
|
|
216
|
+
return True
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mixpanel-openfeature
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OpenFeature provider for the Mixpanel Python SDK
|
|
5
|
+
Author-email: "Mixpanel, Inc." <dev@mixpanel.com>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Requires-Dist: mixpanel<6,>=5.1.0
|
|
9
|
+
Requires-Dist: openfeature-sdk>=0.7.0
|
|
10
|
+
Provides-Extra: test
|
|
11
|
+
Requires-Dist: pytest>=8.4.1; extra == "test"
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "test"
|
|
13
|
+
Requires-Dist: pytest-cov>=6.0; extra == "test"
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
mixpanel_openfeature/__init__.py,sha256=MqU-UaesQNJ7_LES67k6U1HkxreOKiPUdD-z34u4NQU,71
|
|
2
|
+
mixpanel_openfeature/provider.py,sha256=-1LO3jMXFNpsWEksA_C-gPhlV1IzT2wl69YV5QY143c,8116
|
|
3
|
+
mixpanel_openfeature-0.1.0.dist-info/METADATA,sha256=jaZ9H7qYsIE056HfNGRVGvK5mTZtm6qC48j5ZuC4JMk,468
|
|
4
|
+
mixpanel_openfeature-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
mixpanel_openfeature-0.1.0.dist-info/top_level.txt,sha256=P-cmeqnVn9YRmw4kwvwOqw-R7SIeSCpPhl3OvrDnJWs,21
|
|
6
|
+
mixpanel_openfeature-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mixpanel_openfeature
|