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.
- mixpanel_openfeature-0.1.0/PKG-INFO +13 -0
- mixpanel_openfeature-0.1.0/pyproject.toml +98 -0
- mixpanel_openfeature-0.1.0/setup.cfg +4 -0
- mixpanel_openfeature-0.1.0/src/mixpanel_openfeature/__init__.py +3 -0
- mixpanel_openfeature-0.1.0/src/mixpanel_openfeature/provider.py +216 -0
- mixpanel_openfeature-0.1.0/src/mixpanel_openfeature.egg-info/PKG-INFO +13 -0
- mixpanel_openfeature-0.1.0/src/mixpanel_openfeature.egg-info/SOURCES.txt +9 -0
- mixpanel_openfeature-0.1.0/src/mixpanel_openfeature.egg-info/dependency_links.txt +1 -0
- mixpanel_openfeature-0.1.0/src/mixpanel_openfeature.egg-info/requires.txt +7 -0
- mixpanel_openfeature-0.1.0/src/mixpanel_openfeature.egg-info/top_level.txt +1 -0
- mixpanel_openfeature-0.1.0/tests/test_provider.py +452 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mixpanel_openfeature
|
|
@@ -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
|