posthog 6.7.8__py3-none-any.whl → 6.7.10__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.
posthog/__init__.py CHANGED
@@ -11,6 +11,7 @@ from posthog.contexts import (
11
11
  set_context_session as inner_set_context_session,
12
12
  identify_context as inner_identify_context,
13
13
  )
14
+ from posthog.feature_flags import InconclusiveMatchError, RequiresServerEvaluation
14
15
  from posthog.types import FeatureFlag, FlagsAndPayloads, FeatureFlagResult
15
16
  from posthog.version import VERSION
16
17
 
posthog/client.py CHANGED
@@ -20,7 +20,11 @@ from posthog.exception_utils import (
20
20
  exception_is_already_captured,
21
21
  mark_exception_as_captured,
22
22
  )
23
- from posthog.feature_flags import InconclusiveMatchError, match_feature_flag_properties
23
+ from posthog.feature_flags import (
24
+ InconclusiveMatchError,
25
+ RequiresServerEvaluation,
26
+ match_feature_flag_properties,
27
+ )
24
28
  from posthog.poller import Poller
25
29
  from posthog.request import (
26
30
  DEFAULT_HOST,
@@ -1583,7 +1587,7 @@ class Client(object):
1583
1587
  self.log.debug(
1584
1588
  f"Successfully computed flag locally: {key} -> {response}"
1585
1589
  )
1586
- except InconclusiveMatchError as e:
1590
+ except (RequiresServerEvaluation, InconclusiveMatchError) as e:
1587
1591
  self.log.debug(f"Failed to compute flag {key} locally: {e}")
1588
1592
  except Exception as e:
1589
1593
  self.log.exception(
posthog/feature_flags.py CHANGED
@@ -22,6 +22,18 @@ class InconclusiveMatchError(Exception):
22
22
  pass
23
23
 
24
24
 
25
+ class RequiresServerEvaluation(Exception):
26
+ """
27
+ Raised when feature flag evaluation requires server-side data that is not
28
+ available locally (e.g., static cohorts, experience continuity).
29
+
30
+ This error should propagate immediately to trigger API fallback, unlike
31
+ InconclusiveMatchError which allows trying other conditions.
32
+ """
33
+
34
+ pass
35
+
36
+
25
37
  # This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
26
38
  # Given the same distinct_id and key, it'll always return the same float. These floats are
27
39
  # uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
@@ -239,7 +251,12 @@ def match_feature_flag_properties(
239
251
  else:
240
252
  variant = get_matching_variant(flag, distinct_id)
241
253
  return variant or True
254
+ except RequiresServerEvaluation:
255
+ # Static cohort or other missing server-side data - must fallback to API
256
+ raise
242
257
  except InconclusiveMatchError:
258
+ # Evaluation error (bad regex, invalid date, missing property, etc.)
259
+ # Track that we had an inconclusive match, but try other conditions
243
260
  is_inconclusive = True
244
261
 
245
262
  if is_inconclusive:
@@ -449,8 +466,8 @@ def match_cohort(
449
466
  # }
450
467
  cohort_id = str(property.get("value"))
451
468
  if cohort_id not in cohort_properties:
452
- raise InconclusiveMatchError(
453
- "can't match cohort without a given cohort property value"
469
+ raise RequiresServerEvaluation(
470
+ f"cohort {cohort_id} not found in local cohorts - likely a static cohort that requires server evaluation"
454
471
  )
455
472
 
456
473
  property_group = cohort_properties[cohort_id]
@@ -503,6 +520,9 @@ def match_property_group(
503
520
  # OR group
504
521
  if matches:
505
522
  return True
523
+ except RequiresServerEvaluation:
524
+ # Immediately propagate - this condition requires server-side data
525
+ raise
506
526
  except InconclusiveMatchError as e:
507
527
  log.debug(f"Failed to compute property {prop} locally: {e}")
508
528
  error_matching_locally = True
@@ -552,6 +572,9 @@ def match_property_group(
552
572
  return True
553
573
  if not matches and negation:
554
574
  return True
575
+ except RequiresServerEvaluation:
576
+ # Immediately propagate - this condition requires server-side data
577
+ raise
555
578
  except InconclusiveMatchError as e:
556
579
  log.debug(f"Failed to compute property {prop} locally: {e}")
557
580
  error_matching_locally = True
@@ -3,13 +3,19 @@ from posthog import contexts
3
3
  from posthog.client import Client
4
4
 
5
5
  try:
6
- from asgiref.sync import iscoroutinefunction
6
+ from asgiref.sync import iscoroutinefunction, markcoroutinefunction
7
7
  except ImportError:
8
- # Fallback for older Django versions
8
+ # Fallback for older Django versions without asgiref
9
9
  import asyncio
10
10
 
11
11
  iscoroutinefunction = asyncio.iscoroutinefunction
12
12
 
13
+ # No-op fallback for markcoroutinefunction
14
+ # Older Django versions without asgiref typically don't support async middleware anyway
15
+ def markcoroutinefunction(func):
16
+ return func
17
+
18
+
13
19
  if TYPE_CHECKING:
14
20
  from django.http import HttpRequest, HttpResponse # noqa: F401
15
21
  from typing import Callable, Dict, Any, Optional, Union, Awaitable # noqa: F401
@@ -39,26 +45,24 @@ class PosthogContextMiddleware:
39
45
  See the context documentation for more information. The extracted distinct ID and session ID, if found, are used to
40
46
  associate all events captured in the middleware context with the same distinct ID and session as currently active on the
41
47
  frontend. See the documentation for `set_context_session` and `identify_context` for more details.
48
+
49
+ This middleware is hybrid-capable: it supports both WSGI (sync) and ASGI (async) Django applications. The middleware
50
+ detects at initialization whether the next middleware in the chain is async or sync, and adapts its behavior accordingly.
51
+ This ensures compatibility with both pure sync and pure async middleware chains, as well as mixed chains in ASGI mode.
42
52
  """
43
53
 
44
- # Django middleware capability flags
45
54
  sync_capable = True
46
55
  async_capable = True
47
56
 
48
57
  def __init__(self, get_response):
49
58
  # type: (Union[Callable[[HttpRequest], HttpResponse], Callable[[HttpRequest], Awaitable[HttpResponse]]]) -> None
59
+ self.get_response = get_response
50
60
  self._is_coroutine = iscoroutinefunction(get_response)
51
- self._async_get_response = None # type: Optional[Callable[[HttpRequest], Awaitable[HttpResponse]]]
52
- self._sync_get_response = None # type: Optional[Callable[[HttpRequest], HttpResponse]]
53
61
 
62
+ # Mark this instance as a coroutine function if get_response is async
63
+ # This is required for Django to correctly detect async middleware
54
64
  if self._is_coroutine:
55
- self._async_get_response = cast(
56
- "Callable[[HttpRequest], Awaitable[HttpResponse]]", get_response
57
- )
58
- else:
59
- self._sync_get_response = cast(
60
- "Callable[[HttpRequest], HttpResponse]", get_response
61
- )
65
+ markcoroutinefunction(self)
62
66
 
63
67
  from django.conf import settings
64
68
 
@@ -181,40 +185,38 @@ class PosthogContextMiddleware:
181
185
  return user_id, email
182
186
 
183
187
  def __call__(self, request):
184
- # type: (HttpRequest) -> HttpResponse
185
- # Purely defensive around django's internal sync/async handling - this should be unreachable, but if it's reached, we may
186
- # as well return something semi-meaningful
187
- if self._is_coroutine:
188
- raise RuntimeError(
189
- "PosthogContextMiddleware received sync call but get_response is async"
190
- )
188
+ # type: (HttpRequest) -> Union[HttpResponse, Awaitable[HttpResponse]]
189
+ """
190
+ Unified entry point for both sync and async request handling.
191
191
 
192
- if self.request_filter and not self.request_filter(request):
193
- assert self._sync_get_response is not None
194
- return self._sync_get_response(request)
192
+ When sync_capable and async_capable are both True, Django passes requests
193
+ without conversion. This method detects the mode and routes accordingly.
194
+ """
195
+ if self._is_coroutine:
196
+ return self.__acall__(request)
197
+ else:
198
+ # Synchronous path
199
+ if self.request_filter and not self.request_filter(request):
200
+ return self.get_response(request)
195
201
 
196
- with contexts.new_context(self.capture_exceptions, client=self.client):
197
- for k, v in self.extract_tags(request).items():
198
- contexts.tag(k, v)
202
+ with contexts.new_context(self.capture_exceptions, client=self.client):
203
+ for k, v in self.extract_tags(request).items():
204
+ contexts.tag(k, v)
199
205
 
200
- assert self._sync_get_response is not None
201
- return self._sync_get_response(request)
206
+ return self.get_response(request)
202
207
 
203
208
  async def __acall__(self, request):
204
- # type: (HttpRequest) -> HttpResponse
209
+ # type: (HttpRequest) -> Awaitable[HttpResponse]
210
+ """
211
+ Asynchronous entry point for async request handling.
212
+
213
+ This method is called when the middleware chain is async.
214
+ """
205
215
  if self.request_filter and not self.request_filter(request):
206
- if self._async_get_response is not None:
207
- return await self._async_get_response(request)
208
- else:
209
- assert self._sync_get_response is not None
210
- return self._sync_get_response(request)
216
+ return await self.get_response(request)
211
217
 
212
218
  with contexts.new_context(self.capture_exceptions, client=self.client):
213
219
  for k, v in self.extract_tags(request).items():
214
220
  contexts.tag(k, v)
215
221
 
216
- if self._async_get_response is not None:
217
- return await self._async_get_response(request)
218
- else:
219
- assert self._sync_get_response is not None
220
- return self._sync_get_response(request)
222
+ return await self.get_response(request)
@@ -3013,6 +3013,75 @@ class TestLocalEvaluation(unittest.TestCase):
3013
3013
  )
3014
3014
  self.assertEqual(patch_flags.call_count, 0)
3015
3015
 
3016
+ @mock.patch("posthog.client.flags")
3017
+ @mock.patch("posthog.client.get")
3018
+ def test_fallback_to_api_when_flag_has_static_cohort_in_multi_condition(
3019
+ self, patch_get, patch_flags
3020
+ ):
3021
+ """
3022
+ When a flag has multiple conditions and one contains a static cohort,
3023
+ the SDK should fallback to API for the entire flag, not just skip that
3024
+ condition and evaluate the next one locally.
3025
+
3026
+ This prevents returning wrong variants when later conditions could match
3027
+ locally but the user is actually in the static cohort.
3028
+ """
3029
+ client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
3030
+
3031
+ # Mock the local flags response - cohort 999 is NOT in cohorts map (static cohort)
3032
+ client.feature_flags = [
3033
+ {
3034
+ "id": 1,
3035
+ "key": "multi-condition-flag",
3036
+ "active": True,
3037
+ "filters": {
3038
+ "groups": [
3039
+ {
3040
+ "properties": [
3041
+ {"key": "id", "value": 999, "type": "cohort"}
3042
+ ],
3043
+ "rollout_percentage": 100,
3044
+ "variant": "set-1",
3045
+ },
3046
+ {
3047
+ "properties": [
3048
+ {
3049
+ "key": "$geoip_country_code",
3050
+ "operator": "exact",
3051
+ "value": ["DE"],
3052
+ "type": "person",
3053
+ }
3054
+ ],
3055
+ "rollout_percentage": 100,
3056
+ "variant": "set-8",
3057
+ },
3058
+ ],
3059
+ "multivariate": {
3060
+ "variants": [
3061
+ {"key": "set-1", "rollout_percentage": 50},
3062
+ {"key": "set-8", "rollout_percentage": 50},
3063
+ ]
3064
+ },
3065
+ },
3066
+ }
3067
+ ]
3068
+ client.cohorts = {} # Note: cohort 999 is NOT here - it's a static cohort
3069
+
3070
+ # Mock the API response - user is in the static cohort
3071
+ patch_flags.return_value = {"featureFlags": {"multi-condition-flag": "set-1"}}
3072
+
3073
+ result = client.get_feature_flag(
3074
+ "multi-condition-flag",
3075
+ "test-distinct-id",
3076
+ person_properties={"$geoip_country_code": "DE"},
3077
+ )
3078
+
3079
+ # Should return the API result (set-1), not local evaluation (set-8)
3080
+ self.assertEqual(result, "set-1")
3081
+
3082
+ # Verify API was called (fallback occurred)
3083
+ self.assertEqual(patch_flags.call_count, 1)
3084
+
3016
3085
 
3017
3086
  class TestMatchProperties(unittest.TestCase):
3018
3087
  def property(self, key, value, operator=None):
@@ -4006,6 +4075,60 @@ class TestCaptureCalls(unittest.TestCase):
4006
4075
 
4007
4076
  patch_capture.reset_mock()
4008
4077
 
4078
+ @mock.patch("posthog.client.flags")
4079
+ def test_fallback_to_api_in_get_feature_flag_payload_when_flag_has_static_cohort(
4080
+ self, patch_flags
4081
+ ):
4082
+ """
4083
+ Test that get_feature_flag_payload falls back to API when evaluating
4084
+ a flag with static cohorts, similar to get_feature_flag behavior.
4085
+ """
4086
+ client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
4087
+
4088
+ # Mock the local flags response - cohort 999 is NOT in cohorts map (static cohort)
4089
+ client.feature_flags = [
4090
+ {
4091
+ "id": 1,
4092
+ "name": "Multi-condition Flag",
4093
+ "key": "multi-condition-flag",
4094
+ "active": True,
4095
+ "filters": {
4096
+ "groups": [
4097
+ {
4098
+ "properties": [
4099
+ {"key": "id", "value": 999, "type": "cohort"}
4100
+ ],
4101
+ "rollout_percentage": 100,
4102
+ "variant": "variant-1",
4103
+ }
4104
+ ],
4105
+ "multivariate": {
4106
+ "variants": [{"key": "variant-1", "rollout_percentage": 100}]
4107
+ },
4108
+ "payloads": {"variant-1": '{"message": "local-payload"}'},
4109
+ },
4110
+ }
4111
+ ]
4112
+ client.cohorts = {} # Note: cohort 999 is NOT here - it's a static cohort
4113
+
4114
+ # Mock the API response - user is in the static cohort
4115
+ patch_flags.return_value = {
4116
+ "featureFlags": {"multi-condition-flag": "variant-1"},
4117
+ "featureFlagPayloads": {"multi-condition-flag": '{"message": "from-api"}'},
4118
+ }
4119
+
4120
+ # Call get_feature_flag_payload without match_value to trigger evaluation
4121
+ result = client.get_feature_flag_payload(
4122
+ "multi-condition-flag",
4123
+ "test-distinct-id",
4124
+ )
4125
+
4126
+ # Should return the API payload, not local payload
4127
+ self.assertEqual(result, {"message": "from-api"})
4128
+
4129
+ # Verify API was called (fallback occurred)
4130
+ self.assertEqual(patch_flags.call_count, 1)
4131
+
4009
4132
  @mock.patch.object(Client, "capture")
4010
4133
  @mock.patch("posthog.client.flags")
4011
4134
  def test_disable_geoip_get_flag_capture_call(self, patch_flags, patch_capture):
posthog/version.py CHANGED
@@ -1,4 +1,4 @@
1
- VERSION = "6.7.8"
1
+ VERSION = "6.7.10"
2
2
 
3
3
  if __name__ == "__main__":
4
4
  print(VERSION, end="") # noqa: T201
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: posthog
3
- Version: 6.7.8
3
+ Version: 6.7.10
4
4
  Summary: Integrate PostHog into any python application.
5
5
  Home-page: https://github.com/posthog/posthog-python
6
6
  Author: Posthog
@@ -1,17 +1,17 @@
1
- posthog/__init__.py,sha256=IQ-522ve1cw6pMIQ1DNiQkRPktx0gVTvJTTBH8hokCA,25762
1
+ posthog/__init__.py,sha256=2TAAWFhDImqv2_5muq5WcRPXYzdYBDE2XUbH4hnpqeM,25845
2
2
  posthog/args.py,sha256=JUt0vbtF33IzLt3ARgsxMEYYnZo3RNS_LcK4-CjWaco,3298
3
- posthog/client.py,sha256=r4uITrHRivmcle0_sN9T84ZqpxrxTSn9xL_0o-W2HSs,72553
3
+ posthog/client.py,sha256=w19Y2qfNBBuumrFTu5mBDgs5csO4VWP3TL0xFxZvUto,72624
4
4
  posthog/consumer.py,sha256=fdteMZ-deJGMpaQmHyznw_cwQG2Vvld1tmN9LUkZPrY,4608
5
5
  posthog/contexts.py,sha256=FWdM84ibI7jJEKpNGVnjTXi7PWBQRpDUjLOuFkLxFYI,9387
6
6
  posthog/exception_capture.py,sha256=pmKtjQ6QY6zs4u_-ZA4H1gCyR3iI4sfqCQG_jwe_bKo,1774
7
7
  posthog/exception_utils.py,sha256=iutrudjJXwsAbOvMjAI6bKcIL9pzT5NaX-5cqHS9uZ8,26687
8
- posthog/feature_flags.py,sha256=3WQ7VizGtY7sjOulWtEPIPn1jMTbz0_4LRSpQxI5SwU,21503
8
+ posthog/feature_flags.py,sha256=4xAcYEpa97b5Lv9bIo5JHbCO6lhYBnH5EmJ2MrjbU3k,22517
9
9
  posthog/poller.py,sha256=jBz5rfH_kn_bBz7wCB46Fpvso4ttx4uzqIZWvXBCFmQ,595
10
10
  posthog/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
11
  posthog/request.py,sha256=CaONBN7a5RD8xiSShVMgHEd9XxKWM6ZQTLZypiqABhA,6168
12
12
  posthog/types.py,sha256=Dl3aFGX9XUR0wMmK12r2s5Hjan9jL4HpQ9GHpVcEq5U,10207
13
13
  posthog/utils.py,sha256=-0w-OLcCaoldkbBebPzQyBzLJSo9G9yBOg8NDVz7La8,16088
14
- posthog/version.py,sha256=4iDNXV-2xOCt5RDshhzgyNrqpcL_RSoP8Uvd1yWariU,87
14
+ posthog/version.py,sha256=nZjqu-65D7o-byHYYEIUo6_Aa-szIb7lGhSsPoJkJas,88
15
15
  posthog/ai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
16
  posthog/ai/sanitization.py,sha256=owipZ4eJYtd4JTI-CM_klatclXaeaIec3XJBOUfsOnQ,5770
17
17
  posthog/ai/types.py,sha256=ceubs4K9xf8vQx7wokq1NL9hPtxyS7D7sUOuT7Lx1lM,3237
@@ -32,7 +32,7 @@ posthog/ai/openai/openai_async.py,sha256=QFewU-8VBW1BhrNHkKCFuW57jqu2RSnF2VZ9mDu
32
32
  posthog/ai/openai/openai_converter.py,sha256=B38g_RgN1n7mbxbDDAZFcu50RO4UHCtJQAmpvDVry7o,20451
33
33
  posthog/ai/openai/openai_providers.py,sha256=zQIFTXHS2-dBKQX7FZxTFo7rIj5iiN7VHm9_2RzuDs8,3941
34
34
  posthog/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
35
- posthog/integrations/django.py,sha256=2IHm9ft6iAEf_NZfJPAcNlHaH9WJ_ckVFTRpnHb8UvE,8619
35
+ posthog/integrations/django.py,sha256=7z7YsF46KTe11WfgX__qHpYau_jFt-9BacNYY9GI17A,8667
36
36
  posthog/test/__init__.py,sha256=VYgM6xPbJbvS-xhIcDiBRs0MFC9V_jT65uNeerCz_rM,299
37
37
  posthog/test/test_before_send.py,sha256=3546WKlk8rF6bhvqhwcxAsjJovDw0Hf8yTvcYGbrhyI,7912
38
38
  posthog/test/test_client.py,sha256=F-jUA0uKgHpVLOdnen2j6WSTp6whlJcpZdSecLoREFg,96273
@@ -41,14 +41,14 @@ posthog/test/test_contexts.py,sha256=GDYpQNGhdzyA3--ia3WPao_4dqyLUpkWm1NMVm2L-So
41
41
  posthog/test/test_exception_capture.py,sha256=Zs6PP6xAZpHaHB1FDHSqgkPNlhC7iOhmj1kqwui4Xe8,1054
42
42
  posthog/test/test_feature_flag.py,sha256=yIMJkoRtdJr91Y6Rb0PPlpZWBIR394TgWhccnlf-vYE,6815
43
43
  posthog/test/test_feature_flag_result.py,sha256=jbdTgqlFbgvUlAoRWjguk3IvuzXgN2qbfn77gF_SqJU,15871
44
- posthog/test/test_feature_flags.py,sha256=gP18fvvZxDQpyohS11Pq-_sxK3v1Ngqv_eOi94DO43I,212369
44
+ posthog/test/test_feature_flags.py,sha256=JCSFtHhh60WoClmmuBMctSAOchFNqnZPlpfdi37zTMw,217298
45
45
  posthog/test/test_module.py,sha256=CERR0dTPGsAmd7YBxK0yKeB2Zr2b_Lv7hNQoeJauc9I,813
46
46
  posthog/test/test_request.py,sha256=l19WVyZQc4Iqmh_bpnAFOj4nGRpDK1iO-o5aJDQfFdo,4449
47
47
  posthog/test/test_size_limited_dict.py,sha256=Wom7BkzpHmusHilZy0SV3PNzhw7ucuQgqrx86jf8euo,765
48
48
  posthog/test/test_types.py,sha256=csLuBiz6RMV36cpg9LVIor4Khq6MfjjGxYXodx5VttY,7586
49
49
  posthog/test/test_utils.py,sha256=NUs2bgqrVuMdnKRq52syizgglt5_7wxxZl3dDMun-Tg,9602
50
- posthog-6.7.8.dist-info/licenses/LICENSE,sha256=wGf9JBotDkSygFj43m49oiKlFnpMnn97keiZKF-40vE,2450
51
- posthog-6.7.8.dist-info/METADATA,sha256=oBQ-wajbk0I-05JM4RgSvW9RuKFUoiKF9h2U2UrBzPs,6015
52
- posthog-6.7.8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
53
- posthog-6.7.8.dist-info/top_level.txt,sha256=7FBLsRjIUHVKQsXIhozuI3k-mun1tapp8iZO9EmUPEw,8
54
- posthog-6.7.8.dist-info/RECORD,,
50
+ posthog-6.7.10.dist-info/licenses/LICENSE,sha256=wGf9JBotDkSygFj43m49oiKlFnpMnn97keiZKF-40vE,2450
51
+ posthog-6.7.10.dist-info/METADATA,sha256=xXwTtZo74FQWRV5n4jsS9mG8MqlX1DrGY2qPVlUPaRo,6016
52
+ posthog-6.7.10.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
53
+ posthog-6.7.10.dist-info/top_level.txt,sha256=7FBLsRjIUHVKQsXIhozuI3k-mun1tapp8iZO9EmUPEw,8
54
+ posthog-6.7.10.dist-info/RECORD,,