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,3 @@
1
+ from .provider import MixpanelProvider
2
+
3
+ __all__ = ["MixpanelProvider"]
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ mixpanel_openfeature