mixpanel-openfeature 0.1.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.
@@ -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,98 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mixpanel-openfeature"
7
+ version = "0.1.0"
8
+ description = "OpenFeature provider for the Mixpanel Python SDK"
9
+ license = "Apache-2.0"
10
+ authors = [
11
+ {name = "Mixpanel, Inc.", email = "dev@mixpanel.com"},
12
+ ]
13
+ requires-python = ">=3.9"
14
+ dependencies = [
15
+ "mixpanel>=5.1.0,<6",
16
+ "openfeature-sdk>=0.7.0",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ test = [
21
+ "pytest>=8.4.1",
22
+ "pytest-asyncio>=0.23.0",
23
+ "pytest-cov>=6.0",
24
+ ]
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
28
+
29
+ [tool.pytest.ini_options]
30
+ asyncio_mode = "auto"
31
+
32
+ # --- Ruff configuration (mirrors main project) ---
33
+
34
+ [tool.ruff]
35
+ target-version = "py39"
36
+ line-length = 88
37
+
38
+ [tool.ruff.lint]
39
+ select = ["ALL"]
40
+ ignore = [
41
+ # --- Rule conflicts ---
42
+ "D203", # conflicts with D211 (no-blank-line-before-class)
43
+ "D213", # conflicts with D212 (multi-line-summary-first-line)
44
+ "COM812", # conflicts with ruff formatter
45
+ "ISC001", # conflicts with ruff formatter
46
+
47
+ # --- Type annotations (separate effort) ---
48
+ "ANN", # all annotation rules
49
+
50
+ # --- Docstrings (separate effort) ---
51
+ "D100", # undocumented-public-module
52
+ "D101", # undocumented-public-class
53
+ "D102", # undocumented-public-method
54
+ "D103", # undocumented-public-function
55
+ "D104", # undocumented-public-package
56
+ "D105", # undocumented-magic-method
57
+ "D107", # undocumented-public-init
58
+
59
+ # --- Boolean arguments (public API) ---
60
+ "FBT", # boolean-type-hint / boolean-default / boolean-positional
61
+
62
+ # --- TODO/FIXME enforcement ---
63
+ "TD002", # missing-todo-author
64
+ "TD003", # missing-todo-link
65
+ "FIX001", # line-contains-fixme
66
+ "FIX002", # line-contains-todo
67
+
68
+ # --- Exception message style ---
69
+ "EM101", # raw-string-in-exception
70
+ "EM103", # dot-format-in-exception
71
+ "TRY003", # raise-vanilla-args
72
+
73
+ # --- Other pragmatic exclusions ---
74
+ "PLR0913", # too-many-arguments
75
+ "PLR0911", # too-many-return-statements (_resolve has many type-check branches)
76
+ "E501", # line-too-long (formatter handles code)
77
+ "FA100", # future-rewritable-type-annotation
78
+ "BLE001", # blind-exception (catching Exception in flag resolution is intentional)
79
+ ]
80
+
81
+ [tool.ruff.lint.per-file-ignores]
82
+ "tests/*.py" = [
83
+ "S101", # assert
84
+ "S105", # hardcoded-password-string (test fixtures)
85
+ "S106", # hardcoded-password-func-arg
86
+ "SLF001", # private-member-access
87
+ "PLR2004", # magic-value-comparison
88
+ "D", # all docstring rules
89
+ "PT018", # pytest-composite-assertion
90
+ "INP001", # implicit-namespace-package (no __init__.py in tests)
91
+ "ARG", # unused arguments (lambda stubs in mocks)
92
+ ]
93
+
94
+ [tool.ruff.lint.isort]
95
+ known-first-party = ["mixpanel", "mixpanel_openfeature"]
96
+
97
+ [tool.ruff.lint.pydocstyle]
98
+ convention = "google"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,9 @@
1
+ pyproject.toml
2
+ src/mixpanel_openfeature/__init__.py
3
+ src/mixpanel_openfeature/provider.py
4
+ src/mixpanel_openfeature.egg-info/PKG-INFO
5
+ src/mixpanel_openfeature.egg-info/SOURCES.txt
6
+ src/mixpanel_openfeature.egg-info/dependency_links.txt
7
+ src/mixpanel_openfeature.egg-info/requires.txt
8
+ src/mixpanel_openfeature.egg-info/top_level.txt
9
+ tests/test_provider.py
@@ -0,0 +1,7 @@
1
+ mixpanel<6,>=5.1.0
2
+ openfeature-sdk>=0.7.0
3
+
4
+ [test]
5
+ pytest>=8.4.1
6
+ pytest-asyncio>=0.23.0
7
+ pytest-cov>=6.0
@@ -0,0 +1,452 @@
1
+ from unittest.mock import MagicMock
2
+
3
+ import pytest
4
+ from openfeature.evaluation_context import EvaluationContext
5
+ from openfeature.exception import ErrorCode
6
+ from openfeature.flag_evaluation import Reason
7
+
8
+ from mixpanel.flags.types import SelectedVariant
9
+ from mixpanel_openfeature import MixpanelProvider
10
+
11
+
12
+ @pytest.fixture
13
+ def mock_flags():
14
+ flags = MagicMock()
15
+ flags.are_flags_ready.return_value = True
16
+ return flags
17
+
18
+
19
+ @pytest.fixture
20
+ def provider(mock_flags):
21
+ return MixpanelProvider(mock_flags)
22
+
23
+
24
+ def setup_flag(mock_flags, flag_key, value, variant_key="variant-key"):
25
+ """Configure mock to return a SelectedVariant with the given value."""
26
+ mock_flags.get_variant.side_effect = lambda key, fallback, ctx: (
27
+ SelectedVariant(variant_key=variant_key, variant_value=value)
28
+ if key == flag_key
29
+ else fallback
30
+ )
31
+
32
+
33
+ def setup_flag_not_found(mock_flags, flag_key):
34
+ """Configure mock to return the fallback (identity check triggers FLAG_NOT_FOUND)."""
35
+ mock_flags.get_variant.side_effect = lambda key, fallback, ctx: fallback
36
+
37
+
38
+ # --- Metadata ---
39
+
40
+
41
+ def test_metadata_name(provider):
42
+ assert provider.get_metadata().name == "mixpanel-provider"
43
+
44
+
45
+ # --- Boolean evaluation ---
46
+
47
+
48
+ def test_resolves_boolean_true(provider, mock_flags):
49
+ setup_flag(mock_flags, "bool-flag", True)
50
+ result = provider.resolve_boolean_details("bool-flag", False)
51
+ assert result.value is True
52
+ assert result.reason == Reason.TARGETING_MATCH
53
+ assert result.error_code is None
54
+
55
+
56
+ def test_resolves_boolean_false(provider, mock_flags):
57
+ setup_flag(mock_flags, "bool-flag", False)
58
+ result = provider.resolve_boolean_details("bool-flag", True)
59
+ assert result.value is False
60
+ assert result.reason == Reason.TARGETING_MATCH
61
+
62
+
63
+ # --- String evaluation ---
64
+
65
+
66
+ def test_resolves_string(provider, mock_flags):
67
+ setup_flag(mock_flags, "string-flag", "hello")
68
+ result = provider.resolve_string_details("string-flag", "default")
69
+ assert result.value == "hello"
70
+ assert result.reason == Reason.TARGETING_MATCH
71
+ assert result.error_code is None
72
+
73
+
74
+ # --- Integer evaluation ---
75
+
76
+
77
+ def test_resolves_integer(provider, mock_flags):
78
+ setup_flag(mock_flags, "int-flag", 42)
79
+ result = provider.resolve_integer_details("int-flag", 0)
80
+ assert result.value == 42
81
+ assert result.reason == Reason.TARGETING_MATCH
82
+ assert result.error_code is None
83
+
84
+
85
+ def test_resolves_integer_from_float_no_fraction(provider, mock_flags):
86
+ setup_flag(mock_flags, "int-flag", 42.0)
87
+ result = provider.resolve_integer_details("int-flag", 0)
88
+ assert result.value == 42
89
+ assert isinstance(result.value, int)
90
+ assert result.reason == Reason.TARGETING_MATCH
91
+
92
+
93
+ # --- Float evaluation ---
94
+
95
+
96
+ def test_resolves_float(provider, mock_flags):
97
+ setup_flag(mock_flags, "float-flag", 3.14)
98
+ result = provider.resolve_float_details("float-flag", 0.0)
99
+ assert result.value == pytest.approx(3.14)
100
+ assert result.reason == Reason.TARGETING_MATCH
101
+ assert result.error_code is None
102
+
103
+
104
+ def test_resolves_float_from_integer(provider, mock_flags):
105
+ setup_flag(mock_flags, "float-flag", 42)
106
+ result = provider.resolve_float_details("float-flag", 0.0)
107
+ assert result.value == 42.0
108
+ assert isinstance(result.value, float)
109
+ assert result.reason == Reason.TARGETING_MATCH
110
+
111
+
112
+ # --- Object evaluation ---
113
+
114
+
115
+ def test_resolves_object_with_dict(provider, mock_flags):
116
+ setup_flag(mock_flags, "obj-flag", {"key": "value"})
117
+ result = provider.resolve_object_details("obj-flag", {})
118
+ assert result.value == {"key": "value"}
119
+ assert result.reason == Reason.TARGETING_MATCH
120
+ assert result.error_code is None
121
+
122
+
123
+ def test_resolves_object_with_list(provider, mock_flags):
124
+ setup_flag(mock_flags, "obj-flag", [1, 2, 3])
125
+ result = provider.resolve_object_details("obj-flag", [])
126
+ assert result.value == [1, 2, 3]
127
+ assert result.reason == Reason.TARGETING_MATCH
128
+
129
+
130
+ def test_resolves_object_with_string(provider, mock_flags):
131
+ setup_flag(mock_flags, "obj-flag", "hello")
132
+ result = provider.resolve_object_details("obj-flag", {})
133
+ assert result.value == "hello"
134
+ assert result.reason == Reason.TARGETING_MATCH
135
+
136
+
137
+ def test_resolves_object_with_bool(provider, mock_flags):
138
+ setup_flag(mock_flags, "obj-flag", True)
139
+ result = provider.resolve_object_details("obj-flag", {})
140
+ assert result.value is True
141
+ assert result.reason == Reason.TARGETING_MATCH
142
+
143
+
144
+ # --- Error: FLAG_NOT_FOUND ---
145
+
146
+
147
+ def test_flag_not_found_boolean(provider, mock_flags):
148
+ setup_flag_not_found(mock_flags, "missing-flag")
149
+ result = provider.resolve_boolean_details("missing-flag", True)
150
+ assert result.value is True
151
+ assert result.error_code == ErrorCode.FLAG_NOT_FOUND
152
+ assert result.reason == Reason.DEFAULT
153
+
154
+
155
+ def test_flag_not_found_string(provider, mock_flags):
156
+ setup_flag_not_found(mock_flags, "missing-flag")
157
+ result = provider.resolve_string_details("missing-flag", "fallback")
158
+ assert result.value == "fallback"
159
+ assert result.error_code == ErrorCode.FLAG_NOT_FOUND
160
+ assert result.reason == Reason.DEFAULT
161
+
162
+
163
+ def test_flag_not_found_integer(provider, mock_flags):
164
+ setup_flag_not_found(mock_flags, "missing-flag")
165
+ result = provider.resolve_integer_details("missing-flag", 99)
166
+ assert result.value == 99
167
+ assert result.error_code == ErrorCode.FLAG_NOT_FOUND
168
+ assert result.reason == Reason.DEFAULT
169
+
170
+
171
+ def test_flag_not_found_float(provider, mock_flags):
172
+ setup_flag_not_found(mock_flags, "missing-flag")
173
+ result = provider.resolve_float_details("missing-flag", 1.5)
174
+ assert result.value == 1.5
175
+ assert result.error_code == ErrorCode.FLAG_NOT_FOUND
176
+ assert result.reason == Reason.DEFAULT
177
+
178
+
179
+ def test_flag_not_found_object(provider, mock_flags):
180
+ setup_flag_not_found(mock_flags, "missing-flag")
181
+ result = provider.resolve_object_details("missing-flag", {"default": True})
182
+ assert result.value == {"default": True}
183
+ assert result.error_code == ErrorCode.FLAG_NOT_FOUND
184
+ assert result.reason == Reason.DEFAULT
185
+
186
+
187
+ # --- Error: TYPE_MISMATCH ---
188
+
189
+
190
+ def test_type_mismatch_boolean_gets_string(provider, mock_flags):
191
+ setup_flag(mock_flags, "string-flag", "not-a-bool")
192
+ result = provider.resolve_boolean_details("string-flag", False)
193
+ assert result.value is False
194
+ assert result.error_code == ErrorCode.TYPE_MISMATCH
195
+ assert result.reason == Reason.ERROR
196
+
197
+
198
+ def test_type_mismatch_string_gets_boolean(provider, mock_flags):
199
+ setup_flag(mock_flags, "bool-flag", True)
200
+ result = provider.resolve_string_details("bool-flag", "default")
201
+ assert result.value == "default"
202
+ assert result.error_code == ErrorCode.TYPE_MISMATCH
203
+ assert result.reason == Reason.ERROR
204
+
205
+
206
+ def test_type_mismatch_integer_gets_string(provider, mock_flags):
207
+ setup_flag(mock_flags, "string-flag", "not-a-number")
208
+ result = provider.resolve_integer_details("string-flag", 0)
209
+ assert result.value == 0
210
+ assert result.error_code == ErrorCode.TYPE_MISMATCH
211
+ assert result.reason == Reason.ERROR
212
+
213
+
214
+ def test_type_mismatch_float_gets_string(provider, mock_flags):
215
+ setup_flag(mock_flags, "string-flag", "not-a-number")
216
+ result = provider.resolve_float_details("string-flag", 0.0)
217
+ assert result.value == 0.0
218
+ assert result.error_code == ErrorCode.TYPE_MISMATCH
219
+ assert result.reason == Reason.ERROR
220
+
221
+
222
+ def test_type_mismatch_integer_gets_float_with_fraction(provider, mock_flags):
223
+ setup_flag(mock_flags, "float-flag", 3.14)
224
+ result = provider.resolve_integer_details("float-flag", 0)
225
+ assert result.value == 0
226
+ assert result.error_code == ErrorCode.TYPE_MISMATCH
227
+ assert result.reason == Reason.ERROR
228
+
229
+
230
+ def test_type_mismatch_integer_gets_boolean(provider, mock_flags):
231
+ setup_flag(mock_flags, "bool-flag", True)
232
+ result = provider.resolve_integer_details("bool-flag", 0)
233
+ assert result.value == 0
234
+ assert result.error_code == ErrorCode.TYPE_MISMATCH
235
+ assert result.reason == Reason.ERROR
236
+
237
+
238
+ def test_type_mismatch_float_gets_boolean(provider, mock_flags):
239
+ setup_flag(mock_flags, "bool-flag", True)
240
+ result = provider.resolve_float_details("bool-flag", 0.0)
241
+ assert result.value == 0.0
242
+ assert result.error_code == ErrorCode.TYPE_MISMATCH
243
+ assert result.reason == Reason.ERROR
244
+
245
+
246
+ # --- Error: PROVIDER_NOT_READY ---
247
+
248
+
249
+ def test_provider_not_ready_boolean(mock_flags):
250
+ mock_flags.are_flags_ready.return_value = False
251
+ provider = MixpanelProvider(mock_flags)
252
+ result = provider.resolve_boolean_details("any-flag", True)
253
+ assert result.value is True
254
+ assert result.error_code == ErrorCode.PROVIDER_NOT_READY
255
+ assert result.reason == Reason.ERROR
256
+
257
+
258
+ def test_provider_not_ready_string(mock_flags):
259
+ mock_flags.are_flags_ready.return_value = False
260
+ provider = MixpanelProvider(mock_flags)
261
+ result = provider.resolve_string_details("any-flag", "default")
262
+ assert result.value == "default"
263
+ assert result.error_code == ErrorCode.PROVIDER_NOT_READY
264
+ assert result.reason == Reason.ERROR
265
+
266
+
267
+ def test_provider_not_ready_integer(mock_flags):
268
+ mock_flags.are_flags_ready.return_value = False
269
+ provider = MixpanelProvider(mock_flags)
270
+ result = provider.resolve_integer_details("any-flag", 5)
271
+ assert result.value == 5
272
+ assert result.error_code == ErrorCode.PROVIDER_NOT_READY
273
+ assert result.reason == Reason.ERROR
274
+
275
+
276
+ def test_provider_not_ready_float(mock_flags):
277
+ mock_flags.are_flags_ready.return_value = False
278
+ provider = MixpanelProvider(mock_flags)
279
+ result = provider.resolve_float_details("any-flag", 2.5)
280
+ assert result.value == 2.5
281
+ assert result.error_code == ErrorCode.PROVIDER_NOT_READY
282
+ assert result.reason == Reason.ERROR
283
+
284
+
285
+ def test_provider_not_ready_object(mock_flags):
286
+ mock_flags.are_flags_ready.return_value = False
287
+ provider = MixpanelProvider(mock_flags)
288
+ result = provider.resolve_object_details("any-flag", {"default": True})
289
+ assert result.value == {"default": True}
290
+ assert result.error_code == ErrorCode.PROVIDER_NOT_READY
291
+ assert result.reason == Reason.ERROR
292
+
293
+
294
+ # --- Remote provider (no are_flags_ready) is always ready ---
295
+
296
+
297
+ def test_remote_provider_always_ready():
298
+ remote_flags = MagicMock(spec=[]) # empty spec = no attributes
299
+ remote_flags.get_variant = MagicMock(
300
+ side_effect=lambda key, fallback, ctx: SelectedVariant(
301
+ variant_key="v1", variant_value=True
302
+ )
303
+ )
304
+ provider = MixpanelProvider(remote_flags)
305
+ result = provider.resolve_boolean_details("flag", False)
306
+ assert result.value is True
307
+ assert result.reason == Reason.TARGETING_MATCH
308
+
309
+
310
+ # --- Lifecycle ---
311
+
312
+
313
+ def test_shutdown_is_noop(provider):
314
+ provider.shutdown() # Should not raise
315
+
316
+
317
+ # --- EvaluationContext forwarding ---
318
+
319
+
320
+ def test_forwards_targeting_key(provider, mock_flags):
321
+ setup_flag(mock_flags, "flag", "val")
322
+ ctx = EvaluationContext(targeting_key="user-123")
323
+ provider.resolve_string_details("flag", "default", ctx)
324
+ _, _, user_context = mock_flags.get_variant.call_args[0]
325
+ assert user_context["targetingKey"] == "user-123"
326
+
327
+
328
+ def test_forwards_attributes_flat(provider, mock_flags):
329
+ setup_flag(mock_flags, "flag", "val")
330
+ ctx = EvaluationContext(attributes={"plan": "pro", "beta": True})
331
+ provider.resolve_string_details("flag", "default", ctx)
332
+ _, _, user_context = mock_flags.get_variant.call_args[0]
333
+ assert user_context["plan"] == "pro"
334
+ assert user_context["beta"] is True
335
+
336
+
337
+ def test_forwards_full_context(provider, mock_flags):
338
+ setup_flag(mock_flags, "flag", "val")
339
+ ctx = EvaluationContext(targeting_key="user-456", attributes={"tier": "enterprise"})
340
+ provider.resolve_string_details("flag", "default", ctx)
341
+ _, _, user_context = mock_flags.get_variant.call_args[0]
342
+ assert user_context == {
343
+ "targetingKey": "user-456",
344
+ "tier": "enterprise",
345
+ }
346
+
347
+
348
+ def test_no_context_passes_empty_dict(provider, mock_flags):
349
+ setup_flag(mock_flags, "flag", "val")
350
+ provider.resolve_string_details("flag", "default")
351
+ _, _, user_context = mock_flags.get_variant.call_args[0]
352
+ assert user_context == {}
353
+
354
+
355
+ # --- Variant key passthrough ---
356
+
357
+
358
+ def test_variant_key_present_in_boolean_resolution(provider, mock_flags):
359
+ setup_flag(mock_flags, "bool-flag", True, variant_key="control")
360
+ result = provider.resolve_boolean_details("bool-flag", False)
361
+ assert result.value is True
362
+ assert result.variant == "control"
363
+ assert result.reason == Reason.TARGETING_MATCH
364
+
365
+
366
+ def test_variant_key_present_in_string_resolution(provider, mock_flags):
367
+ setup_flag(mock_flags, "string-flag", "hello", variant_key="treatment-a")
368
+ result = provider.resolve_string_details("string-flag", "default")
369
+ assert result.value == "hello"
370
+ assert result.variant == "treatment-a"
371
+ assert result.reason == Reason.TARGETING_MATCH
372
+
373
+
374
+ def test_variant_key_present_in_integer_resolution(provider, mock_flags):
375
+ setup_flag(mock_flags, "int-flag", 42, variant_key="v2")
376
+ result = provider.resolve_integer_details("int-flag", 0)
377
+ assert result.value == 42
378
+ assert result.variant == "v2"
379
+ assert result.reason == Reason.TARGETING_MATCH
380
+
381
+
382
+ def test_variant_key_present_in_float_resolution(provider, mock_flags):
383
+ setup_flag(mock_flags, "float-flag", 3.14, variant_key="v3")
384
+ result = provider.resolve_float_details("float-flag", 0.0)
385
+ assert result.value == pytest.approx(3.14)
386
+ assert result.variant == "v3"
387
+ assert result.reason == Reason.TARGETING_MATCH
388
+
389
+
390
+ def test_variant_key_present_in_object_resolution(provider, mock_flags):
391
+ setup_flag(mock_flags, "obj-flag", {"key": "value"}, variant_key="v4")
392
+ result = provider.resolve_object_details("obj-flag", {})
393
+ assert result.value == {"key": "value"}
394
+ assert result.variant == "v4"
395
+ assert result.reason == Reason.TARGETING_MATCH
396
+
397
+
398
+ # --- SDK exception handling ---
399
+
400
+
401
+ def test_sdk_exception_returns_default_boolean(provider, mock_flags):
402
+ mock_flags.get_variant.side_effect = RuntimeError("SDK failure")
403
+ result = provider.resolve_boolean_details("flag", True)
404
+ assert result.value is True
405
+ assert result.error_code == ErrorCode.GENERAL
406
+ assert result.reason == Reason.ERROR
407
+
408
+
409
+ def test_sdk_exception_returns_default_string(provider, mock_flags):
410
+ mock_flags.get_variant.side_effect = RuntimeError("SDK failure")
411
+ result = provider.resolve_string_details("flag", "fallback")
412
+ assert result.value == "fallback"
413
+ assert result.error_code == ErrorCode.GENERAL
414
+ assert result.reason == Reason.ERROR
415
+
416
+
417
+ def test_sdk_exception_returns_default_integer(provider, mock_flags):
418
+ mock_flags.get_variant.side_effect = RuntimeError("SDK failure")
419
+ result = provider.resolve_integer_details("flag", 99)
420
+ assert result.value == 99
421
+ assert result.error_code == ErrorCode.GENERAL
422
+ assert result.reason == Reason.ERROR
423
+
424
+
425
+ # --- Null variant key ---
426
+
427
+
428
+ def test_null_variant_key_boolean(provider, mock_flags):
429
+ setup_flag(mock_flags, "flag", True, variant_key=None)
430
+ result = provider.resolve_boolean_details("flag", False)
431
+ assert result.value is True
432
+ assert result.variant is None
433
+ assert result.reason == Reason.TARGETING_MATCH
434
+ assert result.error_code is None
435
+
436
+
437
+ def test_null_variant_key_string(provider, mock_flags):
438
+ setup_flag(mock_flags, "flag", "hello", variant_key=None)
439
+ result = provider.resolve_string_details("flag", "default")
440
+ assert result.value == "hello"
441
+ assert result.variant is None
442
+ assert result.reason == Reason.TARGETING_MATCH
443
+ assert result.error_code is None
444
+
445
+
446
+ def test_null_variant_key_object(provider, mock_flags):
447
+ setup_flag(mock_flags, "flag", {"key": "value"}, variant_key=None)
448
+ result = provider.resolve_object_details("flag", {})
449
+ assert result.value == {"key": "value"}
450
+ assert result.variant is None
451
+ assert result.reason == Reason.TARGETING_MATCH
452
+ assert result.error_code is None