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.
- openfeature/contrib/provider/posthog/__init__.py +3 -0
- openfeature/contrib/provider/posthog/provider.py +285 -0
- openfeature/contrib/provider/posthog/py.typed +0 -0
- openfeature_provider_posthog-0.1.1.dist-info/METADATA +29 -0
- openfeature_provider_posthog-0.1.1.dist-info/RECORD +8 -0
- openfeature_provider_posthog-0.1.1.dist-info/WHEEL +5 -0
- openfeature_provider_posthog-0.1.1.dist-info/licenses/LICENSE +19 -0
- openfeature_provider_posthog-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -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,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
|