openfeature-provider-posthog 0.1.1__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,19 @@
1
+ Copyright (c) 2023 PostHog (part of Hiberly Inc)
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: openfeature-provider-posthog
3
+ Version: 0.1.1
4
+ Summary: Official PostHog provider for the OpenFeature Python SDK.
5
+ Author-email: PostHog <engineering@posthog.com>
6
+ Maintainer-email: PostHog <engineering@posthog.com>
7
+ License: MIT
8
+ Project-URL: Homepage, https://github.com/posthog/posthog-python
9
+ Project-URL: Repository, https://github.com/posthog/posthog-python
10
+ Project-URL: Documentation, https://posthog.com/docs/feature-flags/installation/openfeature
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Requires-Python: >=3.10
22
+ License-File: LICENSE
23
+ Requires-Dist: posthog<8.0.0,>=7.0.0
24
+ Requires-Dist: openfeature-sdk>=0.8.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: mypy; extra == "dev"
27
+ Requires-Dist: ruff; extra == "dev"
28
+ Requires-Dist: pytest; extra == "dev"
29
+ Dynamic: license-file
@@ -0,0 +1,3 @@
1
+ from openfeature.contrib.provider.posthog.provider import PostHogProvider
2
+
3
+ __all__ = ["PostHogProvider"]
@@ -0,0 +1,285 @@
1
+ """Official PostHog provider for the OpenFeature Python SDK.
2
+
3
+ This wraps a configured :class:`posthog.Posthog` client and exposes flag
4
+ evaluation through OpenFeature's :class:`~openfeature.provider.AbstractProvider`
5
+ contract, using the modern, single-call ``Client.get_feature_flag_result`` API.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import Any, Callable, Mapping, Optional, Sequence, TypeVar, Union
12
+
13
+ from openfeature.evaluation_context import EvaluationContext
14
+ from openfeature.exception import (
15
+ FlagNotFoundError,
16
+ TargetingKeyMissingError,
17
+ TypeMismatchError,
18
+ )
19
+ from openfeature.flag_evaluation import FlagResolutionDetails, Reason
20
+ from openfeature.provider import AbstractProvider
21
+ from openfeature.provider.metadata import Metadata
22
+
23
+ import posthog
24
+ from posthog.types import FeatureFlagResult
25
+
26
+ # Reserved evaluation-context attribute keys. Every other attribute in
27
+ # ``evaluation_context.attributes`` is forwarded as a PostHog person property.
28
+ GROUPS_KEY = "groups"
29
+ GROUP_PROPERTIES_KEY = "group_properties"
30
+ _RESERVED_KEYS = frozenset({GROUPS_KEY, GROUP_PROPERTIES_KEY})
31
+
32
+ ObjectValue = Union[Sequence[Any], Mapping[str, Any]]
33
+
34
+ _T = TypeVar("_T")
35
+ _N = TypeVar("_N", int, float)
36
+
37
+ _logger = logging.getLogger(__name__)
38
+
39
+
40
+ class PostHogProvider(AbstractProvider):
41
+ """OpenFeature provider backed by a configured :class:`posthog.Posthog` client.
42
+
43
+ The caller owns the PostHog client lifecycle: construct and configure the
44
+ client yourself (project key, ``personal_api_key`` for local evaluation,
45
+ ``host``, ...), then hand it to this provider.
46
+
47
+ Evaluation-context mapping:
48
+ * ``targeting_key`` -> PostHog ``distinct_id``
49
+ * reserved attr ``groups`` -> PostHog ``groups``
50
+ * reserved attr ``group_properties`` -> PostHog ``group_properties``
51
+ * every other attribute -> PostHog ``person_properties``
52
+
53
+ Flag-type mapping (all via ``get_feature_flag_result``):
54
+ * boolean -> ``enabled``
55
+ * string -> the multivariate ``variant`` key
56
+ * int/float -> the ``variant`` parsed to a number
57
+ * object -> the flag's JSON ``payload``
58
+
59
+ Args:
60
+ client: A configured :class:`posthog.Posthog` instance.
61
+ default_distinct_id: Distinct ID to use when the evaluation context has
62
+ no ``targeting_key``. If ``None`` (default), a missing targeting key
63
+ raises :class:`~openfeature.exception.TargetingKeyMissingError`,
64
+ which is OpenFeature-idiomatic. Set a value (e.g. ``"anonymous"``)
65
+ to opt into anonymous evaluation.
66
+ send_feature_flag_events: Forwarded to ``get_feature_flag_result`` to
67
+ control ``$feature_flag_called`` capture. Defaults to ``True`` so
68
+ PostHog flag analytics (and experiments) keep working.
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ client: posthog.Posthog,
74
+ *,
75
+ default_distinct_id: Optional[str] = None,
76
+ send_feature_flag_events: bool = True,
77
+ ) -> None:
78
+ super().__init__()
79
+ self._client = client
80
+ self._default_distinct_id = default_distinct_id
81
+ self._send_feature_flag_events = send_feature_flag_events
82
+
83
+ # -- metadata / lifecycle -------------------------------------------------
84
+
85
+ def get_metadata(self) -> Metadata:
86
+ return Metadata(name="PostHogProvider")
87
+
88
+ def initialize(self, evaluation_context: EvaluationContext) -> None:
89
+ # Preload locally-evaluated flag definitions only when the injected
90
+ # client is configured for local evaluation. We do not otherwise mutate
91
+ # the caller-owned client, and a preload failure must not make the
92
+ # OpenFeature client un-ready (remote evaluation still works).
93
+ if getattr(self._client, "personal_api_key", None):
94
+ try:
95
+ self._client.load_feature_flags()
96
+ except Exception:
97
+ # Don't block the OpenFeature client on a preload failure
98
+ # (remote evaluation still works), but surface it: an invalid
99
+ # personal_api_key, unreachable host, or missing permissions
100
+ # would otherwise silently disable local evaluation.
101
+ _logger.warning(
102
+ "PostHogProvider: failed to preload feature flag "
103
+ "definitions for local evaluation; falling back to remote "
104
+ "evaluation.",
105
+ exc_info=True,
106
+ )
107
+
108
+ def shutdown(self) -> None:
109
+ # The provider does not own the injected client's lifecycle, so this is
110
+ # deliberately a no-op. Callers shut down their own ``Posthog`` client.
111
+ return None
112
+
113
+ # -- core resolution ------------------------------------------------------
114
+
115
+ def _resolve(
116
+ self,
117
+ flag_key: str,
118
+ evaluation_context: Optional[EvaluationContext],
119
+ ) -> FeatureFlagResult:
120
+ distinct_id = self._distinct_id(evaluation_context)
121
+ person_properties, groups, group_properties = self._split_context(
122
+ evaluation_context
123
+ )
124
+ result = self._client.get_feature_flag_result(
125
+ flag_key,
126
+ distinct_id,
127
+ groups=groups or None,
128
+ person_properties=person_properties or None,
129
+ group_properties=group_properties or None,
130
+ send_feature_flag_events=self._send_feature_flag_events,
131
+ )
132
+ if result is None:
133
+ raise FlagNotFoundError(f"Flag '{flag_key}' not found or disabled.")
134
+ return result
135
+
136
+ # -- typed resolvers ------------------------------------------------------
137
+
138
+ def resolve_boolean_details(
139
+ self,
140
+ flag_key: str,
141
+ default_value: bool,
142
+ evaluation_context: Optional[EvaluationContext] = None,
143
+ ) -> FlagResolutionDetails[bool]:
144
+ result = self._resolve(flag_key, evaluation_context)
145
+ return self._details(result.enabled, result)
146
+
147
+ def resolve_string_details(
148
+ self,
149
+ flag_key: str,
150
+ default_value: str,
151
+ evaluation_context: Optional[EvaluationContext] = None,
152
+ ) -> FlagResolutionDetails[str]:
153
+ result = self._resolve(flag_key, evaluation_context)
154
+ if result.variant is None:
155
+ if not result.enabled:
156
+ # The user matched no condition / the flag is off. This is not a
157
+ # type error: return the caller's default with a normal reason.
158
+ return self._details(default_value, result)
159
+ # Enabled but no variant => a boolean flag read as a string. Surface
160
+ # a type mismatch so the caller gets its default per the OF spec
161
+ # rather than a surprising "True"/"False".
162
+ raise TypeMismatchError(
163
+ f"Flag '{flag_key}' has no string variant (boolean flag)."
164
+ )
165
+ return self._details(result.variant, result)
166
+
167
+ def resolve_integer_details(
168
+ self,
169
+ flag_key: str,
170
+ default_value: int,
171
+ evaluation_context: Optional[EvaluationContext] = None,
172
+ ) -> FlagResolutionDetails[int]:
173
+ return self._resolve_number(flag_key, default_value, evaluation_context, int)
174
+
175
+ def resolve_float_details(
176
+ self,
177
+ flag_key: str,
178
+ default_value: float,
179
+ evaluation_context: Optional[EvaluationContext] = None,
180
+ ) -> FlagResolutionDetails[float]:
181
+ return self._resolve_number(flag_key, default_value, evaluation_context, float)
182
+
183
+ def resolve_object_details(
184
+ self,
185
+ flag_key: str,
186
+ default_value: ObjectValue,
187
+ evaluation_context: Optional[EvaluationContext] = None,
188
+ ) -> FlagResolutionDetails[ObjectValue]:
189
+ result = self._resolve(flag_key, evaluation_context)
190
+ payload = result.payload # already JSON-deserialized by posthog
191
+ if not isinstance(payload, (dict, list)):
192
+ if not result.enabled:
193
+ # Non-enrolled / disabled flag: return the default with a normal
194
+ # reason rather than flagging it as a type error.
195
+ return self._details(default_value, result)
196
+ # Matched flag with no object payload => a genuine type mismatch.
197
+ raise TypeMismatchError(f"Flag '{flag_key}' has no object/JSON payload.")
198
+ return self._details(payload, result)
199
+
200
+ def _resolve_number(
201
+ self,
202
+ flag_key: str,
203
+ default_value: _N,
204
+ evaluation_context: Optional[EvaluationContext],
205
+ ctor: Callable[[str], _N],
206
+ ) -> FlagResolutionDetails[_N]:
207
+ result = self._resolve(flag_key, evaluation_context)
208
+ if result.variant is None:
209
+ if not result.enabled:
210
+ # Non-enrolled / disabled flag: not a type error.
211
+ return self._details(default_value, result)
212
+ raise TypeMismatchError(
213
+ f"Flag '{flag_key}' has no variant to parse as {ctor.__name__}."
214
+ )
215
+ try:
216
+ value = ctor(result.variant)
217
+ except (TypeError, ValueError) as exc:
218
+ raise TypeMismatchError(
219
+ f"Flag '{flag_key}' variant '{result.variant}' is not a valid "
220
+ f"{ctor.__name__}."
221
+ ) from exc
222
+ return self._details(value, result)
223
+
224
+ def _details(
225
+ self, value: _T, result: FeatureFlagResult
226
+ ) -> FlagResolutionDetails[_T]:
227
+ """Build resolution details with our shared reason/metadata wiring."""
228
+ return FlagResolutionDetails(
229
+ value=value,
230
+ variant=result.variant,
231
+ reason=self._map_reason(result),
232
+ flag_metadata=self._flag_metadata(result),
233
+ )
234
+
235
+ # -- helpers --------------------------------------------------------------
236
+
237
+ def _distinct_id(self, evaluation_context: Optional[EvaluationContext]) -> str:
238
+ if evaluation_context is not None and evaluation_context.targeting_key:
239
+ return evaluation_context.targeting_key
240
+ if self._default_distinct_id is not None:
241
+ return self._default_distinct_id
242
+ raise TargetingKeyMissingError(
243
+ "No targeting_key in evaluation context and no default_distinct_id "
244
+ "configured."
245
+ )
246
+
247
+ @staticmethod
248
+ def _split_context(
249
+ evaluation_context: Optional[EvaluationContext],
250
+ ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
251
+ if evaluation_context is None or not evaluation_context.attributes:
252
+ return {}, {}, {}
253
+ attrs = evaluation_context.attributes
254
+ groups = attrs.get(GROUPS_KEY) or {}
255
+ group_properties = attrs.get(GROUP_PROPERTIES_KEY) or {}
256
+ person_properties = {k: v for k, v in attrs.items() if k not in _RESERVED_KEYS}
257
+ groups = groups if isinstance(groups, dict) else {}
258
+ group_properties = (
259
+ group_properties if isinstance(group_properties, dict) else {}
260
+ )
261
+ return person_properties, groups, group_properties
262
+
263
+ @staticmethod
264
+ def _map_reason(result: FeatureFlagResult) -> Reason:
265
+ """Map PostHog's free-text reason / enabled state to an OpenFeature Reason."""
266
+ if result.enabled:
267
+ # Enabled: the user matched a targeting condition (or was assigned a
268
+ # variant). PostHog has no distinct OpenFeature-style reason here.
269
+ return Reason.TARGETING_MATCH
270
+ # Not enabled. ``get_feature_flag_result`` returns ``None`` (surfaced as
271
+ # ``FlagNotFoundError`` one level up) for archived/non-existent flags, so
272
+ # a ``False`` result overwhelmingly means the flag is active but no
273
+ # targeting condition matched -> ``DEFAULT``. Only report ``DISABLED``
274
+ # (the flag itself is turned off) when the reason text says so.
275
+ text = (result.reason or "").lower()
276
+ if "disabled" in text:
277
+ return Reason.DISABLED
278
+ return Reason.DEFAULT
279
+
280
+ @staticmethod
281
+ def _flag_metadata(result: FeatureFlagResult) -> Mapping[str, Any]:
282
+ meta: dict[str, Any] = {}
283
+ if result.reason is not None:
284
+ meta["posthog_reason"] = result.reason
285
+ return meta
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: openfeature-provider-posthog
3
+ Version: 0.1.1
4
+ Summary: Official PostHog provider for the OpenFeature Python SDK.
5
+ Author-email: PostHog <engineering@posthog.com>
6
+ Maintainer-email: PostHog <engineering@posthog.com>
7
+ License: MIT
8
+ Project-URL: Homepage, https://github.com/posthog/posthog-python
9
+ Project-URL: Repository, https://github.com/posthog/posthog-python
10
+ Project-URL: Documentation, https://posthog.com/docs/feature-flags/installation/openfeature
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Requires-Python: >=3.10
22
+ License-File: LICENSE
23
+ Requires-Dist: posthog<8.0.0,>=7.0.0
24
+ Requires-Dist: openfeature-sdk>=0.8.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: mypy; extra == "dev"
27
+ Requires-Dist: ruff; extra == "dev"
28
+ Requires-Dist: pytest; extra == "dev"
29
+ Dynamic: license-file
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ pyproject.toml
3
+ openfeature/contrib/provider/posthog/__init__.py
4
+ openfeature/contrib/provider/posthog/provider.py
5
+ openfeature/contrib/provider/posthog/py.typed
6
+ openfeature_provider_posthog.egg-info/PKG-INFO
7
+ openfeature_provider_posthog.egg-info/SOURCES.txt
8
+ openfeature_provider_posthog.egg-info/dependency_links.txt
9
+ openfeature_provider_posthog.egg-info/requires.txt
10
+ openfeature_provider_posthog.egg-info/top_level.txt
11
+ tests/test_provider_e2e.py
12
+ tests/test_provider_unit.py
@@ -0,0 +1,7 @@
1
+ posthog<8.0.0,>=7.0.0
2
+ openfeature-sdk>=0.8.0
3
+
4
+ [dev]
5
+ mypy
6
+ ruff
7
+ pytest
@@ -0,0 +1,69 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "openfeature-provider-posthog"
7
+ version = "0.1.1"
8
+ description = "Official PostHog provider for the OpenFeature Python SDK."
9
+ authors = [{ name = "PostHog", email = "engineering@posthog.com" }]
10
+ maintainers = [{ name = "PostHog", email = "engineering@posthog.com" }]
11
+ license = { text = "MIT" }
12
+ requires-python = ">=3.10"
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "Operating System :: OS Independent",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
+ ]
25
+ dependencies = [
26
+ # FeatureFlagResult / get_feature_flag_result are the modern, non-deprecated
27
+ # single-call API. Pinned to the posthog major this provider is released and
28
+ # tested against (both ship from this repo); bump the upper bound when moving
29
+ # the provider to a new posthog major.
30
+ "posthog>=7.0.0,<8.0.0",
31
+ "openfeature-sdk>=0.8.0",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/posthog/posthog-python"
36
+ Repository = "https://github.com/posthog/posthog-python"
37
+ Documentation = "https://posthog.com/docs/feature-flags/installation/openfeature"
38
+
39
+ [project.optional-dependencies]
40
+ dev = [
41
+ "mypy",
42
+ "ruff",
43
+ "pytest",
44
+ ]
45
+
46
+ [tool.setuptools]
47
+ packages = ["openfeature.contrib.provider.posthog"]
48
+
49
+ [tool.setuptools.package-data]
50
+ "openfeature.contrib.provider.posthog" = ["py.typed"]
51
+
52
+ # Local development: resolve posthog from the uv workspace (the repo root),
53
+ # declared in the root pyproject's [tool.uv.workspace]. This guarantees the
54
+ # provider is always built/tested against the in-repo posthog.
55
+ [tool.uv.sources]
56
+ posthog = { workspace = true }
57
+
58
+ [tool.pytest.ini_options]
59
+ testpaths = ["tests"]
60
+
61
+ # This sub-project runs its own mypy (with openfeature-sdk installed in its env).
62
+ [tool.mypy]
63
+ python_version = "3.10"
64
+ namespace_packages = true
65
+ explicit_package_bases = true
66
+ mypy_path = "."
67
+ ignore_missing_imports = true
68
+ strict_optional = true
69
+ check_untyped_defs = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,56 @@
1
+ """End-to-end tests through the OpenFeature public evaluation API."""
2
+
3
+ from openfeature import api
4
+ from openfeature.evaluation_context import EvaluationContext
5
+
6
+ from openfeature.contrib.provider.posthog import PostHogProvider
7
+
8
+ from tests.conftest import make_result
9
+
10
+
11
+ def _register(fake_client):
12
+ api.set_provider(PostHogProvider(fake_client, default_distinct_id="anon"))
13
+ return api.get_client()
14
+
15
+
16
+ def test_end_to_end_boolean_true(fake_client):
17
+ fake_client.get_feature_flag_result.return_value = make_result(enabled=True)
18
+ client = _register(fake_client)
19
+ ctx = EvaluationContext(targeting_key="user-123")
20
+ assert client.get_boolean_value("flag", False, ctx) is True
21
+
22
+
23
+ def test_end_to_end_string_variant(fake_client):
24
+ fake_client.get_feature_flag_result.return_value = make_result(
25
+ enabled=True, variant="blue"
26
+ )
27
+ client = _register(fake_client)
28
+ ctx = EvaluationContext(targeting_key="user-123")
29
+ assert client.get_string_value("exp", "control", ctx) == "blue"
30
+
31
+
32
+ def test_end_to_end_object_payload(fake_client):
33
+ fake_client.get_feature_flag_result.return_value = make_result(
34
+ enabled=True, variant="v1", payload={"hex": "#00f"}
35
+ )
36
+ client = _register(fake_client)
37
+ ctx = EvaluationContext(targeting_key="user-123")
38
+ assert client.get_object_value("cfg", {}, ctx) == {"hex": "#00f"}
39
+
40
+
41
+ def test_end_to_end_default_on_missing_flag(fake_client):
42
+ fake_client.get_feature_flag_result.return_value = None
43
+ client = _register(fake_client)
44
+ ctx = EvaluationContext(targeting_key="user-123")
45
+ # FlagNotFoundError inside the provider -> SDK returns the caller's default.
46
+ assert client.get_boolean_value("missing", True, ctx) is True
47
+
48
+
49
+ def test_end_to_end_default_on_type_mismatch(fake_client):
50
+ # Boolean flag (no variant) read as a string -> TYPE_MISMATCH -> default.
51
+ fake_client.get_feature_flag_result.return_value = make_result(
52
+ enabled=True, variant=None
53
+ )
54
+ client = _register(fake_client)
55
+ ctx = EvaluationContext(targeting_key="user-123")
56
+ assert client.get_string_value("flag", "fallback", ctx) == "fallback"
@@ -0,0 +1,250 @@
1
+ import pytest
2
+ from openfeature.evaluation_context import EvaluationContext
3
+ from openfeature.exception import (
4
+ FlagNotFoundError,
5
+ TargetingKeyMissingError,
6
+ TypeMismatchError,
7
+ )
8
+ from openfeature.flag_evaluation import Reason
9
+
10
+ from openfeature.contrib.provider.posthog import PostHogProvider
11
+
12
+ from tests.conftest import make_result
13
+
14
+
15
+ def _provider(fake_client, **kwargs):
16
+ return PostHogProvider(fake_client, default_distinct_id="anon", **kwargs)
17
+
18
+
19
+ def test_metadata(fake_client):
20
+ assert _provider(fake_client).get_metadata().name == "PostHogProvider"
21
+
22
+
23
+ @pytest.mark.parametrize(
24
+ ("enabled", "reason", "expected_value", "expected_reason"),
25
+ [
26
+ (True, "matched condition set 1", True, Reason.TARGETING_MATCH),
27
+ # Active flag, user matched nothing -> DEFAULT (not DISABLED).
28
+ (False, "no condition set matched", False, Reason.DEFAULT),
29
+ # Only an explicitly-disabled flag maps to DISABLED.
30
+ (False, "flag is disabled", False, Reason.DISABLED),
31
+ ],
32
+ )
33
+ def test_boolean_reason_mapping(
34
+ fake_client, enabled, reason, expected_value, expected_reason
35
+ ):
36
+ fake_client.get_feature_flag_result.return_value = make_result(
37
+ enabled=enabled, variant=None, reason=reason
38
+ )
39
+ details = _provider(fake_client).resolve_boolean_details(
40
+ "flag", not expected_value, EvaluationContext("user-1")
41
+ )
42
+ assert details.value is expected_value
43
+ assert details.reason == expected_reason
44
+ assert details.flag_metadata["posthog_reason"] == reason
45
+ fake_client.get_feature_flag_result.assert_called_once()
46
+
47
+
48
+ def test_string_variant(fake_client):
49
+ fake_client.get_feature_flag_result.return_value = make_result(
50
+ enabled=True, variant="control"
51
+ )
52
+ details = _provider(fake_client).resolve_string_details(
53
+ "exp", "x", EvaluationContext("user-1")
54
+ )
55
+ assert details.value == "control"
56
+ assert details.variant == "control"
57
+
58
+
59
+ def test_string_on_boolean_flag_is_type_mismatch(fake_client):
60
+ fake_client.get_feature_flag_result.return_value = make_result(
61
+ enabled=True, variant=None
62
+ )
63
+ with pytest.raises(TypeMismatchError):
64
+ _provider(fake_client).resolve_string_details(
65
+ "flag", "x", EvaluationContext("user-1")
66
+ )
67
+
68
+
69
+ @pytest.mark.parametrize(
70
+ ("resolver", "variant", "expected"),
71
+ [
72
+ ("resolve_integer_details", "42", 42),
73
+ ("resolve_integer_details", "3", 3),
74
+ ("resolve_float_details", "3.5", 3.5),
75
+ ("resolve_float_details", "3", 3.0),
76
+ ],
77
+ )
78
+ def test_number_variant_parse(fake_client, resolver, variant, expected):
79
+ fake_client.get_feature_flag_result.return_value = make_result(
80
+ enabled=True, variant=variant
81
+ )
82
+ details = getattr(_provider(fake_client), resolver)("n", 0, EvaluationContext("u"))
83
+ assert details.value == expected
84
+
85
+
86
+ @pytest.mark.parametrize(
87
+ ("resolver", "variant"),
88
+ [
89
+ ("resolve_integer_details", "not-an-int"),
90
+ ("resolve_integer_details", None),
91
+ ("resolve_float_details", "abc"),
92
+ ("resolve_float_details", None),
93
+ ],
94
+ )
95
+ def test_number_variant_parse_failure(fake_client, resolver, variant):
96
+ fake_client.get_feature_flag_result.return_value = make_result(
97
+ enabled=True, variant=variant
98
+ )
99
+ with pytest.raises(TypeMismatchError):
100
+ getattr(_provider(fake_client), resolver)("n", 0, EvaluationContext("u"))
101
+
102
+
103
+ def test_object_payload(fake_client):
104
+ fake_client.get_feature_flag_result.return_value = make_result(
105
+ enabled=True, variant="v1", payload={"color": "blue"}
106
+ )
107
+ details = _provider(fake_client).resolve_object_details(
108
+ "cfg", {}, EvaluationContext("u")
109
+ )
110
+ assert details.value == {"color": "blue"}
111
+
112
+
113
+ def test_object_payload_list(fake_client):
114
+ fake_client.get_feature_flag_result.return_value = make_result(
115
+ enabled=True, variant="v1", payload=[1, 2, 3]
116
+ )
117
+ details = _provider(fake_client).resolve_object_details(
118
+ "cfg", {}, EvaluationContext("u")
119
+ )
120
+ assert details.value == [1, 2, 3]
121
+
122
+
123
+ def test_object_missing_payload_is_type_mismatch(fake_client):
124
+ fake_client.get_feature_flag_result.return_value = make_result(
125
+ enabled=True, variant="v1", payload=None
126
+ )
127
+ with pytest.raises(TypeMismatchError):
128
+ _provider(fake_client).resolve_object_details("cfg", {}, EvaluationContext("u"))
129
+
130
+
131
+ # A user who matches no condition / a disabled flag (enabled=False, variant/payload
132
+ # absent) is NOT a type error: the default is returned with a normal reason and no
133
+ # error_code, instead of TYPE_MISMATCH / Reason.ERROR.
134
+ def test_string_unmatched_returns_default_without_error(fake_client):
135
+ fake_client.get_feature_flag_result.return_value = make_result(
136
+ enabled=False, variant=None, reason="no condition set matched"
137
+ )
138
+ details = _provider(fake_client).resolve_string_details(
139
+ "exp", "fallback", EvaluationContext("user-1")
140
+ )
141
+ assert details.value == "fallback"
142
+ assert details.reason == Reason.DEFAULT
143
+ assert details.error_code is None
144
+
145
+
146
+ @pytest.mark.parametrize(
147
+ "resolver", ["resolve_integer_details", "resolve_float_details"]
148
+ )
149
+ def test_number_unmatched_returns_default_without_error(fake_client, resolver):
150
+ fake_client.get_feature_flag_result.return_value = make_result(
151
+ enabled=False, variant=None, reason="no condition set matched"
152
+ )
153
+ details = getattr(_provider(fake_client), resolver)("n", 7, EvaluationContext("u"))
154
+ assert details.value == 7
155
+ assert details.reason == Reason.DEFAULT
156
+ assert details.error_code is None
157
+
158
+
159
+ def test_object_unmatched_returns_default_without_error(fake_client):
160
+ fake_client.get_feature_flag_result.return_value = make_result(
161
+ enabled=False, variant=None, payload=None, reason="no condition set matched"
162
+ )
163
+ default = {"fallback": True}
164
+ details = _provider(fake_client).resolve_object_details(
165
+ "cfg", default, EvaluationContext("u")
166
+ )
167
+ assert details.value == default
168
+ assert details.reason == Reason.DEFAULT
169
+ assert details.error_code is None
170
+
171
+
172
+ def test_flag_not_found_raises(fake_client):
173
+ fake_client.get_feature_flag_result.return_value = None
174
+ with pytest.raises(FlagNotFoundError):
175
+ _provider(fake_client).resolve_boolean_details(
176
+ "missing", False, EvaluationContext("u")
177
+ )
178
+
179
+
180
+ def test_missing_targeting_key_no_default(fake_client):
181
+ provider = PostHogProvider(fake_client) # no default_distinct_id
182
+ with pytest.raises(TargetingKeyMissingError):
183
+ provider.resolve_boolean_details("flag", False, EvaluationContext())
184
+
185
+
186
+ def test_default_distinct_id_used_when_no_targeting_key(fake_client):
187
+ fake_client.get_feature_flag_result.return_value = make_result()
188
+ _provider(fake_client).resolve_boolean_details("flag", False, EvaluationContext())
189
+ args, _ = fake_client.get_feature_flag_result.call_args
190
+ assert args[1] == "anon"
191
+
192
+
193
+ def test_context_split(fake_client):
194
+ fake_client.get_feature_flag_result.return_value = make_result()
195
+ ctx = EvaluationContext(
196
+ "u",
197
+ {
198
+ "plan": "pro",
199
+ "groups": {"org": "acme"},
200
+ "group_properties": {"org": {"tier": "ent"}},
201
+ },
202
+ )
203
+ _provider(fake_client).resolve_boolean_details("flag", False, ctx)
204
+ kwargs = fake_client.get_feature_flag_result.call_args.kwargs
205
+ assert kwargs["groups"] == {"org": "acme"}
206
+ assert kwargs["group_properties"] == {"org": {"tier": "ent"}}
207
+ assert kwargs["person_properties"] == {"plan": "pro"}
208
+
209
+
210
+ @pytest.mark.parametrize("bad_value", ["acme", ["a", "b"], 42])
211
+ def test_context_split_non_dict_groups_coerced_to_none(fake_client, bad_value):
212
+ # A non-dict `groups` / `group_properties` is coerced to {} and forwarded as
213
+ # None, never passed straight through to get_feature_flag_result.
214
+ fake_client.get_feature_flag_result.return_value = make_result()
215
+ ctx = EvaluationContext("u", {"groups": bad_value, "group_properties": bad_value})
216
+ _provider(fake_client).resolve_boolean_details("flag", False, ctx)
217
+ kwargs = fake_client.get_feature_flag_result.call_args.kwargs
218
+ assert kwargs["groups"] is None
219
+ assert kwargs["group_properties"] is None
220
+
221
+
222
+ def test_send_feature_flag_events_forwarded(fake_client):
223
+ fake_client.get_feature_flag_result.return_value = make_result()
224
+ _provider(fake_client, send_feature_flag_events=False).resolve_boolean_details(
225
+ "flag", False, EvaluationContext("u")
226
+ )
227
+ assert (
228
+ fake_client.get_feature_flag_result.call_args.kwargs["send_feature_flag_events"]
229
+ is False
230
+ )
231
+
232
+
233
+ def test_initialize_skips_preload_without_personal_api_key(fake_client):
234
+ # fake_client.personal_api_key is None by default.
235
+ PostHogProvider(fake_client).initialize(EvaluationContext())
236
+ fake_client.load_feature_flags.assert_not_called()
237
+
238
+
239
+ def test_initialize_logs_warning_on_preload_failure(fake_client, caplog):
240
+ fake_client.personal_api_key = "phx_test"
241
+ fake_client.load_feature_flags.side_effect = RuntimeError("bad key")
242
+ with caplog.at_level("WARNING"):
243
+ PostHogProvider(fake_client).initialize(EvaluationContext())
244
+ fake_client.load_feature_flags.assert_called_once()
245
+ assert "failed to preload" in caplog.text
246
+
247
+
248
+ def test_shutdown_does_not_touch_client(fake_client):
249
+ PostHogProvider(fake_client).shutdown()
250
+ fake_client.shutdown.assert_not_called()