posthoganalytics 7.0.1__py3-none-any.whl → 7.4.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.
Files changed (28) hide show
  1. posthoganalytics/__init__.py +10 -0
  2. posthoganalytics/ai/gemini/__init__.py +3 -0
  3. posthoganalytics/ai/gemini/gemini.py +1 -1
  4. posthoganalytics/ai/gemini/gemini_async.py +423 -0
  5. posthoganalytics/ai/gemini/gemini_converter.py +87 -21
  6. posthoganalytics/ai/openai/openai.py +27 -2
  7. posthoganalytics/ai/openai/openai_async.py +27 -2
  8. posthoganalytics/ai/openai/openai_converter.py +6 -0
  9. posthoganalytics/ai/sanitization.py +27 -5
  10. posthoganalytics/ai/utils.py +2 -2
  11. posthoganalytics/client.py +224 -58
  12. posthoganalytics/exception_utils.py +49 -4
  13. posthoganalytics/flag_definition_cache.py +127 -0
  14. posthoganalytics/request.py +203 -23
  15. posthoganalytics/test/test_client.py +207 -22
  16. posthoganalytics/test/test_exception_capture.py +45 -1
  17. posthoganalytics/test/test_feature_flag_result.py +441 -2
  18. posthoganalytics/test/test_feature_flags.py +166 -73
  19. posthoganalytics/test/test_flag_definition_cache.py +612 -0
  20. posthoganalytics/test/test_request.py +536 -0
  21. posthoganalytics/test/test_utils.py +4 -1
  22. posthoganalytics/types.py +40 -0
  23. posthoganalytics/version.py +1 -1
  24. {posthoganalytics-7.0.1.dist-info → posthoganalytics-7.4.1.dist-info}/METADATA +2 -1
  25. {posthoganalytics-7.0.1.dist-info → posthoganalytics-7.4.1.dist-info}/RECORD +28 -25
  26. {posthoganalytics-7.0.1.dist-info → posthoganalytics-7.4.1.dist-info}/WHEEL +0 -0
  27. {posthoganalytics-7.0.1.dist-info → posthoganalytics-7.4.1.dist-info}/licenses/LICENSE +0 -0
  28. {posthoganalytics-7.0.1.dist-info → posthoganalytics-7.4.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,127 @@
1
+ """
2
+ Flag Definition Cache Provider interface for multi-worker environments.
3
+
4
+ EXPERIMENTAL: This API may change in future minor version bumps.
5
+
6
+ This module provides an interface for external caching of feature flag definitions,
7
+ enabling multi-worker environments (Kubernetes, load-balanced servers, serverless
8
+ functions) to share flag definitions and reduce API calls.
9
+
10
+ Usage:
11
+
12
+ from posthoganalytics import Posthog
13
+ from posthoganalytics.flag_definition_cache import FlagDefinitionCacheProvider
14
+
15
+ cache = RedisFlagDefinitionCache(redis_client, "my-team")
16
+ posthog = Posthog(
17
+ "<project_api_key>",
18
+ personal_api_key="<personal_api_key>",
19
+ flag_definition_cache_provider=cache,
20
+ )
21
+ """
22
+
23
+ from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
24
+
25
+ from typing_extensions import Required, TypedDict
26
+
27
+
28
+ class FlagDefinitionCacheData(TypedDict):
29
+ """
30
+ Data structure for cached flag definitions.
31
+
32
+ Attributes:
33
+ flags: List of feature flag definition dictionaries from the API.
34
+ group_type_mapping: Mapping of group type indices to group names.
35
+ cohorts: Dictionary of cohort definitions for local evaluation.
36
+ """
37
+
38
+ flags: Required[List[Dict[str, Any]]]
39
+ group_type_mapping: Required[Dict[str, str]]
40
+ cohorts: Required[Dict[str, Any]]
41
+
42
+
43
+ @runtime_checkable
44
+ class FlagDefinitionCacheProvider(Protocol):
45
+ """
46
+ Interface for external caching of feature flag definitions.
47
+
48
+ Enables multi-worker environments to share flag definitions, reducing API
49
+ calls while ensuring all workers have consistent data.
50
+
51
+ EXPERIMENTAL: This API may change in future minor version bumps.
52
+
53
+ The four methods handle the complete lifecycle of flag definition caching:
54
+
55
+ 1. `should_fetch_flag_definitions()` - Called before each poll to determine
56
+ if this worker should fetch new definitions. Use for distributed lock
57
+ coordination to ensure only one worker fetches at a time.
58
+
59
+ 2. `get_flag_definitions()` - Called when `should_fetch_flag_definitions()`
60
+ returns False. Returns cached definitions if available.
61
+
62
+ 3. `on_flag_definitions_received()` - Called after successfully fetching
63
+ new definitions from the API. Store the data in your external cache
64
+ and release any locks.
65
+
66
+ 4. `shutdown()` - Called when the PostHog client shuts down. Release any
67
+ distributed locks and clean up resources.
68
+
69
+ Error Handling:
70
+ All methods are wrapped in try/except. Errors will be logged but will
71
+ never break flag evaluation. On error:
72
+ - `should_fetch_flag_definitions()` errors default to fetching (fail-safe)
73
+ - `get_flag_definitions()` errors fall back to API fetch
74
+ - `on_flag_definitions_received()` errors are logged but flags remain in memory
75
+ - `shutdown()` errors are logged but shutdown continues
76
+ """
77
+
78
+ def get_flag_definitions(self) -> Optional[FlagDefinitionCacheData]:
79
+ """
80
+ Retrieve cached flag definitions.
81
+
82
+ Returns:
83
+ Cached flag definitions if available and valid, None otherwise.
84
+ Returning None will trigger a fetch from the API if this worker
85
+ has no flags loaded yet.
86
+ """
87
+ ...
88
+
89
+ def should_fetch_flag_definitions(self) -> bool:
90
+ """
91
+ Determine whether this instance should fetch new flag definitions.
92
+
93
+ Use this for distributed lock coordination. Only one worker should
94
+ return True to avoid thundering herd problems. A typical implementation
95
+ uses a distributed lock (e.g., Redis SETNX) that expires after the
96
+ poll interval.
97
+
98
+ Returns:
99
+ True if this instance should fetch from the API, False otherwise.
100
+ When False, the client will call `get_flag_definitions()` to
101
+ retrieve cached data instead.
102
+ """
103
+ ...
104
+
105
+ def on_flag_definitions_received(self, data: FlagDefinitionCacheData) -> None:
106
+ """
107
+ Called after successfully receiving new flag definitions from PostHog.
108
+
109
+ Use this to store the data in your external cache and release any
110
+ distributed locks acquired in `should_fetch_flag_definitions()`.
111
+
112
+ Args:
113
+ data: The flag definitions to cache, containing flags,
114
+ group_type_mapping, and cohorts.
115
+ """
116
+ ...
117
+
118
+ def shutdown(self) -> None:
119
+ """
120
+ Called when the PostHog client shuts down.
121
+
122
+ Use this to release any distributed locks and clean up resources.
123
+ This method is called even if `should_fetch_flag_definitions()`
124
+ returned False, so implementations should handle the case where
125
+ no lock was acquired.
126
+ """
127
+ ...
@@ -1,28 +1,163 @@
1
1
  import json
2
2
  import logging
3
+ import re
4
+ import socket
5
+ from dataclasses import dataclass
3
6
  from datetime import date, datetime
4
7
  from gzip import GzipFile
5
8
  from io import BytesIO
6
- from typing import Any, Optional, Union
9
+ from typing import Any, List, Optional, Tuple, Union
7
10
 
8
11
  import requests
9
12
  from dateutil.tz import tzutc
13
+ from requests.adapters import HTTPAdapter # type: ignore[import-untyped]
14
+ from urllib3.connection import HTTPConnection
10
15
  from urllib3.util.retry import Retry
11
16
 
12
17
  from posthoganalytics.utils import remove_trailing_slash
13
18
  from posthoganalytics.version import VERSION
14
19
 
15
- # Retry on both connect and read errors
16
- # by default read errors will only retry idempotent HTTP methods (so not POST)
17
- adapter = requests.adapters.HTTPAdapter(
18
- max_retries=Retry(
19
- total=2,
20
- connect=2,
21
- read=2,
20
+ SocketOptions = List[Tuple[int, int, Union[int, bytes]]]
21
+
22
+ KEEPALIVE_IDLE_SECONDS = 60
23
+ KEEPALIVE_INTERVAL_SECONDS = 60
24
+ KEEPALIVE_PROBE_COUNT = 3
25
+
26
+ # TCP keepalive probes idle connections to prevent them from being dropped.
27
+ # SO_KEEPALIVE is cross-platform, but timing options vary:
28
+ # - Linux: TCP_KEEPIDLE, TCP_KEEPINTVL, TCP_KEEPCNT
29
+ # - macOS: only SO_KEEPALIVE (uses system defaults)
30
+ # - Windows: TCP_KEEPIDLE, TCP_KEEPINTVL (since Windows 10 1709)
31
+ KEEP_ALIVE_SOCKET_OPTIONS: SocketOptions = list(
32
+ HTTPConnection.default_socket_options
33
+ ) + [
34
+ (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1),
35
+ ]
36
+ for attr, value in [
37
+ ("TCP_KEEPIDLE", KEEPALIVE_IDLE_SECONDS),
38
+ ("TCP_KEEPINTVL", KEEPALIVE_INTERVAL_SECONDS),
39
+ ("TCP_KEEPCNT", KEEPALIVE_PROBE_COUNT),
40
+ ]:
41
+ if hasattr(socket, attr):
42
+ KEEP_ALIVE_SOCKET_OPTIONS.append((socket.SOL_TCP, getattr(socket, attr), value))
43
+
44
+ # Status codes that indicate transient server errors worth retrying
45
+ RETRY_STATUS_FORCELIST = [408, 500, 502, 503, 504]
46
+
47
+
48
+ def _mask_tokens_in_url(url: str) -> str:
49
+ """Mask token values in URLs for safe logging, keeping first 10 chars visible."""
50
+ return re.sub(r"(token=)([^&]{10})[^&]*", r"\1\2...", url)
51
+
52
+
53
+ @dataclass
54
+ class GetResponse:
55
+ """Response from a GET request with ETag support."""
56
+
57
+ data: Any
58
+ etag: Optional[str] = None
59
+ not_modified: bool = False
60
+
61
+
62
+ class HTTPAdapterWithSocketOptions(HTTPAdapter):
63
+ """HTTPAdapter with configurable socket options."""
64
+
65
+ def __init__(self, *args, socket_options: Optional[SocketOptions] = None, **kwargs):
66
+ self.socket_options = socket_options
67
+ super().__init__(*args, **kwargs)
68
+
69
+ def init_poolmanager(self, *args, **kwargs):
70
+ if self.socket_options is not None:
71
+ kwargs["socket_options"] = self.socket_options
72
+ super().init_poolmanager(*args, **kwargs)
73
+
74
+
75
+ def _build_session(socket_options: Optional[SocketOptions] = None) -> requests.Session:
76
+ """Build a session for general requests (batch, decide, etc.)."""
77
+ adapter = HTTPAdapterWithSocketOptions(
78
+ max_retries=Retry(
79
+ total=2,
80
+ connect=2,
81
+ read=2,
82
+ ),
83
+ socket_options=socket_options,
84
+ )
85
+ session = requests.Session()
86
+ session.mount("https://", adapter)
87
+ return session
88
+
89
+
90
+ def _build_flags_session(
91
+ socket_options: Optional[SocketOptions] = None,
92
+ ) -> requests.Session:
93
+ """
94
+ Build a session for feature flag requests with POST retries.
95
+
96
+ Feature flag requests are idempotent (read-only), so retrying POST
97
+ requests is safe. This session retries on transient server errors
98
+ (408, 5xx) and network failures with exponential backoff
99
+ (0.5s, 1s delays between retries).
100
+ """
101
+ adapter = HTTPAdapterWithSocketOptions(
102
+ max_retries=Retry(
103
+ total=2,
104
+ connect=2,
105
+ read=2,
106
+ backoff_factor=0.5,
107
+ status_forcelist=RETRY_STATUS_FORCELIST,
108
+ allowed_methods=["POST"],
109
+ ),
110
+ socket_options=socket_options,
22
111
  )
23
- )
24
- _session = requests.sessions.Session()
25
- _session.mount("https://", adapter)
112
+ session = requests.Session()
113
+ session.mount("https://", adapter)
114
+ return session
115
+
116
+
117
+ _session = _build_session()
118
+ _flags_session = _build_flags_session()
119
+ _socket_options: Optional[SocketOptions] = None
120
+ _pooling_enabled = True
121
+
122
+
123
+ def _get_session() -> requests.Session:
124
+ if _pooling_enabled:
125
+ return _session
126
+ return _build_session(_socket_options)
127
+
128
+
129
+ def _get_flags_session() -> requests.Session:
130
+ if _pooling_enabled:
131
+ return _flags_session
132
+ return _build_flags_session(_socket_options)
133
+
134
+
135
+ def set_socket_options(socket_options: Optional[SocketOptions]) -> None:
136
+ """
137
+ Configure socket options for all HTTP connections.
138
+
139
+ Example:
140
+ from posthoganalytics import set_socket_options
141
+ set_socket_options([(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)])
142
+ """
143
+ global _session, _flags_session, _socket_options
144
+ if socket_options == _socket_options:
145
+ return
146
+ _socket_options = socket_options
147
+ _session = _build_session(socket_options)
148
+ _flags_session = _build_flags_session(socket_options)
149
+
150
+
151
+ def enable_keep_alive() -> None:
152
+ """Enable TCP keepalive to prevent idle connections from being dropped."""
153
+ set_socket_options(KEEP_ALIVE_SOCKET_OPTIONS)
154
+
155
+
156
+ def disable_connection_reuse() -> None:
157
+ """Disable connection reuse, creating a fresh connection for each request."""
158
+ global _pooling_enabled
159
+ _pooling_enabled = False
160
+
26
161
 
27
162
  US_INGESTION_ENDPOINT = "https://us.i.posthog.com"
28
163
  EU_INGESTION_ENDPOINT = "https://eu.i.posthog.com"
@@ -48,6 +183,7 @@ def post(
48
183
  path=None,
49
184
  gzip: bool = False,
50
185
  timeout: int = 15,
186
+ session: Optional[requests.Session] = None,
51
187
  **kwargs,
52
188
  ) -> requests.Response:
53
189
  """Post the `kwargs` to the API"""
@@ -68,7 +204,9 @@ def post(
68
204
  gz.write(data.encode("utf-8"))
69
205
  data = buf.getvalue()
70
206
 
71
- res = _session.post(url, data=data, headers=headers, timeout=timeout)
207
+ res = (session or _get_session()).post(
208
+ url, data=data, headers=headers, timeout=timeout
209
+ )
72
210
 
73
211
  if res.status_code == 200:
74
212
  log.debug("data uploaded successfully")
@@ -124,8 +262,16 @@ def flags(
124
262
  timeout: int = 15,
125
263
  **kwargs,
126
264
  ) -> Any:
127
- """Post the `kwargs to the flags API endpoint"""
128
- res = post(api_key, host, "/flags/?v=2", gzip, timeout, **kwargs)
265
+ """Post the kwargs to the flags API endpoint with automatic retries."""
266
+ res = post(
267
+ api_key,
268
+ host,
269
+ "/flags/?v=2",
270
+ gzip,
271
+ timeout,
272
+ session=_get_flags_session(),
273
+ **kwargs,
274
+ )
129
275
  return _process_response(
130
276
  res, success_message="Feature flags evaluated successfully"
131
277
  )
@@ -139,12 +285,13 @@ def remote_config(
139
285
  timeout: int = 15,
140
286
  ) -> Any:
141
287
  """Get remote config flag value from remote_config API endpoint"""
142
- return get(
288
+ response = get(
143
289
  personal_api_key,
144
290
  f"/api/projects/@current/feature_flags/{key}/remote_config?token={project_api_key}",
145
291
  host,
146
292
  timeout,
147
293
  )
294
+ return response.data
148
295
 
149
296
 
150
297
  def batch_post(
@@ -162,15 +309,42 @@ def batch_post(
162
309
 
163
310
 
164
311
  def get(
165
- api_key: str, url: str, host: Optional[str] = None, timeout: Optional[int] = None
166
- ) -> requests.Response:
167
- url = remove_trailing_slash(host or DEFAULT_HOST) + url
168
- res = requests.get(
169
- url,
170
- headers={"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT},
171
- timeout=timeout,
312
+ api_key: str,
313
+ url: str,
314
+ host: Optional[str] = None,
315
+ timeout: Optional[int] = None,
316
+ etag: Optional[str] = None,
317
+ ) -> GetResponse:
318
+ """
319
+ Make a GET request with optional ETag support.
320
+
321
+ If an etag is provided, sends If-None-Match header. Returns GetResponse with:
322
+ - not_modified=True and data=None if server returns 304
323
+ - not_modified=False and data=response if server returns 200
324
+ """
325
+ log = logging.getLogger("posthog")
326
+ full_url = remove_trailing_slash(host or DEFAULT_HOST) + url
327
+ headers = {"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT}
328
+
329
+ if etag:
330
+ headers["If-None-Match"] = etag
331
+
332
+ res = _get_session().get(full_url, headers=headers, timeout=timeout)
333
+
334
+ masked_url = _mask_tokens_in_url(full_url)
335
+
336
+ # Handle 304 Not Modified
337
+ if res.status_code == 304:
338
+ log.debug(f"GET {masked_url} returned 304 Not Modified")
339
+ response_etag = res.headers.get("ETag")
340
+ return GetResponse(data=None, etag=response_etag or etag, not_modified=True)
341
+
342
+ # Handle normal response
343
+ data = _process_response(
344
+ res, success_message=f"GET {masked_url} completed successfully"
172
345
  )
173
- return _process_response(res, success_message=f"GET {url} completed successfully")
346
+ response_etag = res.headers.get("ETag")
347
+ return GetResponse(data=data, etag=response_etag, not_modified=False)
174
348
 
175
349
 
176
350
  class APIError(Exception):
@@ -187,6 +361,12 @@ class QuotaLimitError(APIError):
187
361
  pass
188
362
 
189
363
 
364
+ # Re-export requests exceptions for use in client.py
365
+ # This keeps all requests library imports centralized in this module
366
+ RequestsTimeout = requests.exceptions.Timeout
367
+ RequestsConnectionError = requests.exceptions.ConnectionError
368
+
369
+
190
370
  class DatetimeSerializer(json.JSONEncoder):
191
371
  def default(self, obj: Any):
192
372
  if isinstance(obj, (date, datetime)):
@@ -9,7 +9,7 @@ from parameterized import parameterized
9
9
 
10
10
  from posthoganalytics.client import Client
11
11
  from posthoganalytics.contexts import get_context_session_id, new_context, set_context_session
12
- from posthoganalytics.request import APIError
12
+ from posthoganalytics.request import APIError, GetResponse
13
13
  from posthoganalytics.test.test_utils import FAKE_TEST_API_KEY
14
14
  from posthoganalytics.types import FeatureFlag, LegacyFlagMetadata
15
15
  from posthoganalytics.version import VERSION
@@ -198,12 +198,6 @@ class TestClient(unittest.TestCase):
198
198
  print(capture_call)
199
199
  self.assertEqual(capture_call[1]["distinct_id"], "distinct_id")
200
200
  self.assertEqual(capture_call[0][0], "$exception")
201
- self.assertEqual(
202
- capture_call[1]["properties"]["$exception_type"], "Exception"
203
- )
204
- self.assertEqual(
205
- capture_call[1]["properties"]["$exception_message"], "test exception"
206
- )
207
201
  self.assertEqual(
208
202
  capture_call[1]["properties"]["$exception_list"][0]["mechanism"][
209
203
  "type"
@@ -415,7 +409,9 @@ class TestClient(unittest.TestCase):
415
409
  )
416
410
  client.feature_flags = [multivariate_flag, basic_flag, false_flag]
417
411
 
418
- msg_uuid = client.capture("python test event", distinct_id="distinct_id")
412
+ msg_uuid = client.capture(
413
+ "python test event", distinct_id="distinct_id", send_feature_flags=True
414
+ )
419
415
  self.assertIsNotNone(msg_uuid)
420
416
  self.assertFalse(self.failed)
421
417
 
@@ -571,6 +567,7 @@ class TestClient(unittest.TestCase):
571
567
  "python test event",
572
568
  distinct_id="distinct_id",
573
569
  properties={"$feature/beta-feature-local": "my-custom-variant"},
570
+ send_feature_flags=True,
574
571
  )
575
572
  self.assertIsNotNone(msg_uuid)
576
573
  self.assertFalse(self.failed)
@@ -752,6 +749,178 @@ class TestClient(unittest.TestCase):
752
749
 
753
750
  self.assertEqual(patch_flags.call_count, 0)
754
751
 
752
+ @mock.patch("posthog.client.flags")
753
+ def test_capture_with_send_feature_flags_false_and_local_evaluation_doesnt_send_flags(
754
+ self, patch_flags
755
+ ):
756
+ """Test that send_feature_flags=False with local evaluation enabled does NOT send flags"""
757
+ patch_flags.return_value = {"featureFlags": {"beta-feature": "remote-variant"}}
758
+
759
+ multivariate_flag = {
760
+ "id": 1,
761
+ "name": "Beta Feature",
762
+ "key": "beta-feature-local",
763
+ "active": True,
764
+ "rollout_percentage": 100,
765
+ "filters": {
766
+ "groups": [
767
+ {
768
+ "rollout_percentage": 100,
769
+ },
770
+ ],
771
+ "multivariate": {
772
+ "variants": [
773
+ {
774
+ "key": "first-variant",
775
+ "name": "First Variant",
776
+ "rollout_percentage": 50,
777
+ },
778
+ {
779
+ "key": "second-variant",
780
+ "name": "Second Variant",
781
+ "rollout_percentage": 50,
782
+ },
783
+ ]
784
+ },
785
+ },
786
+ }
787
+ simple_flag = {
788
+ "id": 2,
789
+ "name": "Simple Flag",
790
+ "key": "simple-flag",
791
+ "active": True,
792
+ "filters": {
793
+ "groups": [
794
+ {
795
+ "rollout_percentage": 100,
796
+ }
797
+ ],
798
+ },
799
+ }
800
+
801
+ with mock.patch("posthog.client.batch_post") as mock_post:
802
+ client = Client(
803
+ FAKE_TEST_API_KEY,
804
+ on_error=self.set_fail,
805
+ personal_api_key=FAKE_TEST_API_KEY,
806
+ sync_mode=True,
807
+ )
808
+ client.feature_flags = [multivariate_flag, simple_flag]
809
+
810
+ msg_uuid = client.capture(
811
+ "python test event",
812
+ distinct_id="distinct_id",
813
+ send_feature_flags=False,
814
+ )
815
+ self.assertIsNotNone(msg_uuid)
816
+ self.assertFalse(self.failed)
817
+
818
+ # Get the enqueued message from the mock
819
+ mock_post.assert_called_once()
820
+ batch_data = mock_post.call_args[1]["batch"]
821
+ msg = batch_data[0]
822
+
823
+ self.assertEqual(msg["event"], "python test event")
824
+ self.assertEqual(msg["distinct_id"], "distinct_id")
825
+
826
+ # CRITICAL: Verify local flags are NOT included in the event
827
+ self.assertNotIn("$feature/beta-feature-local", msg["properties"])
828
+ self.assertNotIn("$feature/simple-flag", msg["properties"])
829
+ self.assertNotIn("$active_feature_flags", msg["properties"])
830
+
831
+ # CRITICAL: Verify the /flags API was NOT called
832
+ self.assertEqual(patch_flags.call_count, 0)
833
+
834
+ @mock.patch("posthog.client.flags")
835
+ def test_capture_with_send_feature_flags_true_and_local_evaluation_uses_local_flags(
836
+ self, patch_flags
837
+ ):
838
+ """Test that send_feature_flags=True with local evaluation enabled uses local flags without API call"""
839
+ patch_flags.return_value = {"featureFlags": {"remote-flag": "remote-variant"}}
840
+
841
+ multivariate_flag = {
842
+ "id": 1,
843
+ "name": "Beta Feature",
844
+ "key": "beta-feature-local",
845
+ "active": True,
846
+ "rollout_percentage": 100,
847
+ "filters": {
848
+ "groups": [
849
+ {
850
+ "rollout_percentage": 100,
851
+ },
852
+ ],
853
+ "multivariate": {
854
+ "variants": [
855
+ {
856
+ "key": "first-variant",
857
+ "name": "First Variant",
858
+ "rollout_percentage": 50,
859
+ },
860
+ {
861
+ "key": "second-variant",
862
+ "name": "Second Variant",
863
+ "rollout_percentage": 50,
864
+ },
865
+ ]
866
+ },
867
+ },
868
+ }
869
+ simple_flag = {
870
+ "id": 2,
871
+ "name": "Simple Flag",
872
+ "key": "simple-flag",
873
+ "active": True,
874
+ "filters": {
875
+ "groups": [
876
+ {
877
+ "rollout_percentage": 100,
878
+ }
879
+ ],
880
+ },
881
+ }
882
+
883
+ with mock.patch("posthog.client.batch_post") as mock_post:
884
+ client = Client(
885
+ FAKE_TEST_API_KEY,
886
+ on_error=self.set_fail,
887
+ personal_api_key=FAKE_TEST_API_KEY,
888
+ sync_mode=True,
889
+ )
890
+ client.feature_flags = [multivariate_flag, simple_flag]
891
+
892
+ msg_uuid = client.capture(
893
+ "python test event",
894
+ distinct_id="distinct_id",
895
+ send_feature_flags=True,
896
+ )
897
+ self.assertIsNotNone(msg_uuid)
898
+ self.assertFalse(self.failed)
899
+
900
+ # Get the enqueued message from the mock
901
+ mock_post.assert_called_once()
902
+ batch_data = mock_post.call_args[1]["batch"]
903
+ msg = batch_data[0]
904
+
905
+ self.assertEqual(msg["event"], "python test event")
906
+ self.assertEqual(msg["distinct_id"], "distinct_id")
907
+
908
+ # Verify local flags are included in the event
909
+ self.assertIn("$feature/beta-feature-local", msg["properties"])
910
+ self.assertIn("$feature/simple-flag", msg["properties"])
911
+ self.assertEqual(msg["properties"]["$feature/simple-flag"], True)
912
+
913
+ # Verify active feature flags are set correctly
914
+ active_flags = msg["properties"]["$active_feature_flags"]
915
+ self.assertIn("beta-feature-local", active_flags)
916
+ self.assertIn("simple-flag", active_flags)
917
+
918
+ # The remote flag should NOT be included since we used local evaluation
919
+ self.assertNotIn("$feature/remote-flag", msg["properties"])
920
+
921
+ # CRITICAL: Verify the /flags API was NOT called
922
+ self.assertEqual(patch_flags.call_count, 0)
923
+
755
924
  @mock.patch("posthog.client.flags")
756
925
  def test_capture_with_send_feature_flags_options_only_evaluate_locally_true(
757
926
  self, patch_flags
@@ -2095,13 +2264,21 @@ class TestClient(unittest.TestCase):
2095
2264
  self, patch_get, patch_poller
2096
2265
  ):
2097
2266
  """Test that when enable_local_evaluation=False, the poller is not started"""
2098
- patch_get.return_value = {
2099
- "flags": [
2100
- {"id": 1, "name": "Beta Feature", "key": "beta-feature", "active": True}
2101
- ],
2102
- "group_type_mapping": {},
2103
- "cohorts": {},
2104
- }
2267
+ patch_get.return_value = GetResponse(
2268
+ data={
2269
+ "flags": [
2270
+ {
2271
+ "id": 1,
2272
+ "name": "Beta Feature",
2273
+ "key": "beta-feature",
2274
+ "active": True,
2275
+ }
2276
+ ],
2277
+ "group_type_mapping": {},
2278
+ "cohorts": {},
2279
+ },
2280
+ etag='"test-etag"',
2281
+ )
2105
2282
 
2106
2283
  client = Client(
2107
2284
  FAKE_TEST_API_KEY,
@@ -2123,13 +2300,21 @@ class TestClient(unittest.TestCase):
2123
2300
  @mock.patch("posthog.client.get")
2124
2301
  def test_enable_local_evaluation_true_starts_poller(self, patch_get, patch_poller):
2125
2302
  """Test that when enable_local_evaluation=True (default), the poller is started"""
2126
- patch_get.return_value = {
2127
- "flags": [
2128
- {"id": 1, "name": "Beta Feature", "key": "beta-feature", "active": True}
2129
- ],
2130
- "group_type_mapping": {},
2131
- "cohorts": {},
2132
- }
2303
+ patch_get.return_value = GetResponse(
2304
+ data={
2305
+ "flags": [
2306
+ {
2307
+ "id": 1,
2308
+ "name": "Beta Feature",
2309
+ "key": "beta-feature",
2310
+ "active": True,
2311
+ }
2312
+ ],
2313
+ "group_type_mapping": {},
2314
+ "cohorts": {},
2315
+ },
2316
+ etag='"test-etag"',
2317
+ )
2133
2318
 
2134
2319
  client = Client(
2135
2320
  FAKE_TEST_API_KEY,