openfeature-provider-posthog 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ from 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
File without changes
@@ -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,8 @@
1
+ openfeature/contrib/provider/posthog/__init__.py,sha256=XiSYl5BxJsrDAjtPJahMhFlS_3JwGk3VI2aVX5C9nBo,105
2
+ openfeature/contrib/provider/posthog/provider.py,sha256=Pb14Db5mOEEu7MdvuBfYmWkAbg590_WxU9sFRTeZVnU,12092
3
+ openfeature/contrib/provider/posthog/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ openfeature_provider_posthog-0.1.1.dist-info/licenses/LICENSE,sha256=8MxuGvqoZjM-k3hKiV0PL1CENQpYiY5-C3-WHQZx7Bk,1073
5
+ openfeature_provider_posthog-0.1.1.dist-info/METADATA,sha256=2vcI2vlImSu3OHcddePqzR9o6qUj5ZHHzIFUIywREs8,1232
6
+ openfeature_provider_posthog-0.1.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
+ openfeature_provider_posthog-0.1.1.dist-info/top_level.txt,sha256=FF-_9FWc6friAIew3gd8t2010dOrnZA6KLxzgnWV22E,12
8
+ openfeature_provider_posthog-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,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 @@
1
+ openfeature