posthoganalytics 6.0.2__tar.gz → 6.0.4__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.
Files changed (58) hide show
  1. {posthoganalytics-6.0.2/posthoganalytics.egg-info → posthoganalytics-6.0.4}/PKG-INFO +2 -2
  2. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/README.md +1 -1
  3. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/client.py +161 -1
  4. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/integrations/django.py +11 -2
  5. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/test/test_utils.py +123 -0
  6. posthoganalytics-6.0.4/posthoganalytics/utils.py +519 -0
  7. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/version.py +1 -1
  8. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4/posthoganalytics.egg-info}/PKG-INFO +2 -2
  9. posthoganalytics-6.0.2/posthoganalytics/utils.py +0 -257
  10. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/LICENSE +0 -0
  11. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/MANIFEST.in +0 -0
  12. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/__init__.py +0 -0
  13. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/ai/__init__.py +0 -0
  14. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/ai/anthropic/__init__.py +0 -0
  15. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/ai/anthropic/anthropic.py +0 -0
  16. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/ai/anthropic/anthropic_async.py +0 -0
  17. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/ai/anthropic/anthropic_providers.py +0 -0
  18. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/ai/gemini/__init__.py +0 -0
  19. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/ai/gemini/gemini.py +0 -0
  20. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/ai/langchain/__init__.py +0 -0
  21. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/ai/langchain/callbacks.py +0 -0
  22. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/ai/openai/__init__.py +0 -0
  23. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/ai/openai/openai.py +0 -0
  24. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/ai/openai/openai_async.py +0 -0
  25. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/ai/openai/openai_providers.py +0 -0
  26. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/ai/utils.py +0 -0
  27. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/args.py +0 -0
  28. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/consumer.py +0 -0
  29. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/contexts.py +0 -0
  30. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/exception_capture.py +0 -0
  31. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/exception_utils.py +0 -0
  32. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/feature_flags.py +0 -0
  33. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/integrations/__init__.py +0 -0
  34. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/poller.py +0 -0
  35. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/py.typed +0 -0
  36. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/request.py +0 -0
  37. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/test/__init__.py +0 -0
  38. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/test/test_before_send.py +0 -0
  39. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/test/test_client.py +0 -0
  40. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/test/test_consumer.py +0 -0
  41. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/test/test_contexts.py +0 -0
  42. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/test/test_exception_capture.py +0 -0
  43. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/test/test_feature_flag.py +0 -0
  44. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/test/test_feature_flag_result.py +0 -0
  45. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/test/test_feature_flags.py +0 -0
  46. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/test/test_module.py +0 -0
  47. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/test/test_request.py +0 -0
  48. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/test/test_size_limited_dict.py +0 -0
  49. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/test/test_types.py +0 -0
  50. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics/types.py +0 -0
  51. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics.egg-info/SOURCES.txt +0 -0
  52. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics.egg-info/dependency_links.txt +0 -0
  53. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics.egg-info/requires.txt +0 -0
  54. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/posthoganalytics.egg-info/top_level.txt +0 -0
  55. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/pyproject.toml +0 -0
  56. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/setup.cfg +0 -0
  57. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/setup.py +0 -0
  58. {posthoganalytics-6.0.2 → posthoganalytics-6.0.4}/setup_analytics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: posthoganalytics
3
- Version: 6.0.2
3
+ Version: 6.0.4
4
4
  Summary: Integrate PostHog into any python application.
5
5
  Home-page: https://github.com/posthog/posthog-python
6
6
  Author: Posthog
@@ -107,7 +107,7 @@ We recommend using [uv](https://docs.astral.sh/uv/). It's super fast.
107
107
  ```bash
108
108
  uv python install 3.9.19
109
109
  uv python pin 3.9.19
110
- uv venv env
110
+ uv venv
111
111
  source env/bin/activate
112
112
  uv sync --extra dev --extra test
113
113
  pre-commit install
@@ -32,7 +32,7 @@ We recommend using [uv](https://docs.astral.sh/uv/). It's super fast.
32
32
  ```bash
33
33
  uv python install 3.9.19
34
34
  uv python pin 3.9.19
35
- uv venv env
35
+ uv venv
36
36
  source env/bin/activate
37
37
  uv sync --extra dev --extra test
38
38
  pre-commit install
@@ -50,6 +50,8 @@ from posthoganalytics.types import (
50
50
  to_values,
51
51
  )
52
52
  from posthoganalytics.utils import (
53
+ FlagCache,
54
+ RedisFlagCache,
53
55
  SizeLimitedDict,
54
56
  clean,
55
57
  guess_timezone,
@@ -95,7 +97,30 @@ def add_context_tags(properties):
95
97
 
96
98
 
97
99
  class Client(object):
98
- """Create a new PostHog client."""
100
+ """Create a new PostHog client.
101
+
102
+ Examples:
103
+ Basic usage:
104
+ >>> client = Client("your-api-key")
105
+
106
+ With memory-based feature flag fallback cache:
107
+ >>> client = Client(
108
+ ... "your-api-key",
109
+ ... flag_fallback_cache_url="memory://local/?ttl=300&size=10000"
110
+ ... )
111
+
112
+ With Redis fallback cache for high-scale applications:
113
+ >>> client = Client(
114
+ ... "your-api-key",
115
+ ... flag_fallback_cache_url="redis://localhost:6379/0/?ttl=300"
116
+ ... )
117
+
118
+ With Redis authentication:
119
+ >>> client = Client(
120
+ ... "your-api-key",
121
+ ... flag_fallback_cache_url="redis://username:password@localhost:6379/0/?ttl=300"
122
+ ... )
123
+ """
99
124
 
100
125
  log = logging.getLogger("posthog")
101
126
 
@@ -126,6 +151,7 @@ class Client(object):
126
151
  project_root=None,
127
152
  privacy_mode=False,
128
153
  before_send=None,
154
+ flag_fallback_cache_url=None,
129
155
  ):
130
156
  self.queue = queue.Queue(max_queue_size)
131
157
 
@@ -151,6 +177,8 @@ class Client(object):
151
177
  )
152
178
  self.poller = None
153
179
  self.distinct_ids_feature_flags_reported = SizeLimitedDict(MAX_DICT_SIZE, set)
180
+ self.flag_cache = self._initialize_flag_cache(flag_fallback_cache_url)
181
+ self.flag_definition_version = 0
154
182
  self.disabled = disabled
155
183
  self.disable_geoip = disable_geoip
156
184
  self.historical_migration = historical_migration
@@ -707,6 +735,9 @@ class Client(object):
707
735
 
708
736
  def _load_feature_flags(self):
709
737
  try:
738
+ # Store old flags to detect changes
739
+ old_flags_by_key: dict[str, dict] = self.feature_flags_by_key or {}
740
+
710
741
  response = get(
711
742
  self.personal_api_key,
712
743
  f"/api/feature_flag/local_evaluation/?token={self.api_key}&send_cohorts",
@@ -718,6 +749,14 @@ class Client(object):
718
749
  self.group_type_mapping = response["group_type_mapping"] or {}
719
750
  self.cohorts = response["cohorts"] or {}
720
751
 
752
+ # Check if flag definitions changed and update version
753
+ if self.flag_cache and old_flags_by_key != (
754
+ self.feature_flags_by_key or {}
755
+ ):
756
+ old_version = self.flag_definition_version
757
+ self.flag_definition_version += 1
758
+ self.flag_cache.invalidate_version(old_version)
759
+
721
760
  except APIError as e:
722
761
  if e.status == 401:
723
762
  self.log.error(
@@ -739,6 +778,10 @@ class Client(object):
739
778
  self.group_type_mapping = {}
740
779
  self.cohorts = {}
741
780
 
781
+ # Clear flag cache when quota limited
782
+ if self.flag_cache:
783
+ self.flag_cache.clear()
784
+
742
785
  if self.debug:
743
786
  raise APIError(
744
787
  status=402,
@@ -889,6 +932,12 @@ class Client(object):
889
932
  flag_result = FeatureFlagResult.from_value_and_payload(
890
933
  key, lookup_match_value, payload
891
934
  )
935
+
936
+ # Cache successful local evaluation
937
+ if self.flag_cache and flag_result:
938
+ self.flag_cache.set_cached_flag(
939
+ distinct_id, key, flag_result, self.flag_definition_version
940
+ )
892
941
  elif not only_evaluate_locally:
893
942
  try:
894
943
  flag_details, request_id = self._get_feature_flag_details_from_decide(
@@ -902,12 +951,30 @@ class Client(object):
902
951
  flag_result = FeatureFlagResult.from_flag_details(
903
952
  flag_details, override_match_value
904
953
  )
954
+
955
+ # Cache successful remote evaluation
956
+ if self.flag_cache and flag_result:
957
+ self.flag_cache.set_cached_flag(
958
+ distinct_id, key, flag_result, self.flag_definition_version
959
+ )
960
+
905
961
  self.log.debug(
906
962
  f"Successfully computed flag remotely: #{key} -> #{flag_result}"
907
963
  )
908
964
  except Exception as e:
909
965
  self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}")
910
966
 
967
+ # Fallback to cached value if remote evaluation fails
968
+ if self.flag_cache:
969
+ stale_result = self.flag_cache.get_stale_cached_flag(
970
+ distinct_id, key
971
+ )
972
+ if stale_result:
973
+ self.log.info(
974
+ f"[FEATURE FLAGS] Using stale cached value for flag {key}"
975
+ )
976
+ flag_result = stale_result
977
+
911
978
  if send_feature_flag_events:
912
979
  self._capture_feature_flag_called(
913
980
  distinct_id,
@@ -1278,6 +1345,99 @@ class Client(object):
1278
1345
  "featureFlagPayloads": payloads,
1279
1346
  }, fallback_to_decide
1280
1347
 
1348
+ def _initialize_flag_cache(self, cache_url):
1349
+ """Initialize feature flag cache for graceful degradation during service outages.
1350
+
1351
+ When enabled, the cache stores flag evaluation results and serves them as fallback
1352
+ when the PostHog API is unavailable. This ensures your application continues to
1353
+ receive flag values even during outages.
1354
+
1355
+ Args:
1356
+ cache_url: Cache configuration URL. Examples:
1357
+ - None: Disable caching
1358
+ - "memory://local/?ttl=300&size=10000": Memory cache with TTL and size
1359
+ - "redis://localhost:6379/0/?ttl=300": Redis cache with TTL
1360
+ - "redis://username:password@host:port/?ttl=300": Redis with auth
1361
+
1362
+ Example usage:
1363
+ # Memory cache
1364
+ client = Client(
1365
+ "your-api-key",
1366
+ flag_fallback_cache_url="memory://local/?ttl=300&size=10000"
1367
+ )
1368
+
1369
+ # Redis cache
1370
+ client = Client(
1371
+ "your-api-key",
1372
+ flag_fallback_cache_url="redis://localhost:6379/0/?ttl=300"
1373
+ )
1374
+
1375
+ # Normal evaluation - cache is populated
1376
+ flag_value = client.get_feature_flag("my-flag", "user123")
1377
+
1378
+ # During API outage - returns cached value instead of None
1379
+ flag_value = client.get_feature_flag("my-flag", "user123") # Uses cache
1380
+ """
1381
+ if not cache_url:
1382
+ return None
1383
+
1384
+ try:
1385
+ from urllib.parse import urlparse, parse_qs
1386
+ except ImportError:
1387
+ from urlparse import urlparse, parse_qs
1388
+
1389
+ try:
1390
+ parsed = urlparse(cache_url)
1391
+ scheme = parsed.scheme.lower()
1392
+ query_params = parse_qs(parsed.query)
1393
+ ttl = int(query_params.get("ttl", [300])[0])
1394
+
1395
+ if scheme == "memory":
1396
+ size = int(query_params.get("size", [10000])[0])
1397
+ return FlagCache(size, ttl)
1398
+
1399
+ elif scheme == "redis":
1400
+ try:
1401
+ # Not worth importing redis if we're not using it
1402
+ import redis
1403
+
1404
+ redis_url = f"{parsed.scheme}://"
1405
+ if parsed.username or parsed.password:
1406
+ redis_url += f"{parsed.username or ''}:{parsed.password or ''}@"
1407
+ redis_url += (
1408
+ f"{parsed.hostname or 'localhost'}:{parsed.port or 6379}"
1409
+ )
1410
+ if parsed.path:
1411
+ redis_url += parsed.path
1412
+
1413
+ client = redis.from_url(redis_url)
1414
+
1415
+ # Test connection before using it
1416
+ client.ping()
1417
+
1418
+ return RedisFlagCache(client, default_ttl=ttl)
1419
+
1420
+ except ImportError:
1421
+ self.log.warning(
1422
+ "[FEATURE FLAGS] Redis not available, flag caching disabled"
1423
+ )
1424
+ return None
1425
+ except Exception as e:
1426
+ self.log.warning(
1427
+ f"[FEATURE FLAGS] Redis connection failed: {e}, flag caching disabled"
1428
+ )
1429
+ return None
1430
+ else:
1431
+ raise ValueError(
1432
+ f"Unknown cache URL scheme: {scheme}. Supported schemes: memory, redis"
1433
+ )
1434
+
1435
+ except Exception as e:
1436
+ self.log.warning(
1437
+ f"[FEATURE FLAGS] Failed to parse cache URL '{cache_url}': {e}"
1438
+ )
1439
+ return None
1440
+
1281
1441
  def feature_flag_definitions(self):
1282
1442
  return self.feature_flags
1283
1443
 
@@ -1,5 +1,6 @@
1
1
  from typing import TYPE_CHECKING, cast
2
2
  from posthoganalytics import contexts
3
+ from posthoganalytics.client import Client
3
4
 
4
5
  if TYPE_CHECKING:
5
6
  from django.http import HttpRequest, HttpResponse # noqa: F401
@@ -16,7 +17,8 @@ class PosthogContextMiddleware:
16
17
  - Request Method as $request_method
17
18
 
18
19
  The context will also auto-capture exceptions and send them to PostHog, unless you disable it by setting
19
- `POSTHOG_MW_CAPTURE_EXCEPTIONS` to `False` in your Django settings.
20
+ `POSTHOG_MW_CAPTURE_EXCEPTIONS` to `False` in your Django settings. The exceptions are captured using the
21
+ global client, unless the setting `POSTHOG_MW_CLIENT` is set to a custom client instance
20
22
 
21
23
  The middleware behaviour is customisable through 3 additional functions:
22
24
  - `POSTHOG_MW_EXTRA_TAGS`, which is a Callable[[HttpRequest], Dict[str, Any]] expected to return a dictionary of additional tags to be added to the context.
@@ -74,6 +76,13 @@ class PosthogContextMiddleware:
74
76
  else:
75
77
  self.capture_exceptions = True
76
78
 
79
+ if hasattr(settings, "POSTHOG_MW_CLIENT") and isinstance(
80
+ settings.POSTHOG_MW_CLIENT, Client
81
+ ):
82
+ self.client = cast("Optional[Client]", settings.POSTHOG_MW_CLIENT)
83
+ else:
84
+ self.client = None
85
+
77
86
  def extract_tags(self, request):
78
87
  # type: (HttpRequest) -> Dict[str, Any]
79
88
  tags = {}
@@ -153,7 +162,7 @@ class PosthogContextMiddleware:
153
162
  if self.request_filter and not self.request_filter(request):
154
163
  return self.get_response(request)
155
164
 
156
- with contexts.new_context(self.capture_exceptions):
165
+ with contexts.new_context(self.capture_exceptions, client=self.client):
157
166
  for k, v in self.extract_tags(request).items():
158
167
  contexts.tag(k, v)
159
168
 
@@ -1,3 +1,4 @@
1
+ import time
1
2
  import unittest
2
3
  from dataclasses import dataclass
3
4
  from datetime import date, datetime, timedelta
@@ -12,6 +13,7 @@ from pydantic import BaseModel
12
13
  from pydantic.v1 import BaseModel as BaseModelV1
13
14
 
14
15
  from posthoganalytics import utils
16
+ from posthoganalytics.types import FeatureFlagResult
15
17
 
16
18
  TEST_API_KEY = "kOOlRy2QlMY9jHZQv0bKz0FZyazBUoY8Arj0lFVNjs4"
17
19
  FAKE_TEST_API_KEY = "random_key"
@@ -173,3 +175,124 @@ class TestUtils(unittest.TestCase):
173
175
  "inner_optional": None,
174
176
  },
175
177
  }
178
+
179
+
180
+ class TestFlagCache(unittest.TestCase):
181
+ def setUp(self):
182
+ self.cache = utils.FlagCache(max_size=3, default_ttl=1)
183
+ self.flag_result = FeatureFlagResult.from_value_and_payload(
184
+ "test-flag", True, None
185
+ )
186
+
187
+ def test_cache_basic_operations(self):
188
+ distinct_id = "user123"
189
+ flag_key = "test-flag"
190
+ flag_version = 1
191
+
192
+ # Test cache miss
193
+ result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
194
+ assert result is None
195
+
196
+ # Test cache set and hit
197
+ self.cache.set_cached_flag(
198
+ distinct_id, flag_key, self.flag_result, flag_version
199
+ )
200
+ result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
201
+ assert result is not None
202
+ assert result.get_value()
203
+
204
+ def test_cache_ttl_expiration(self):
205
+ distinct_id = "user123"
206
+ flag_key = "test-flag"
207
+ flag_version = 1
208
+
209
+ # Set flag in cache
210
+ self.cache.set_cached_flag(
211
+ distinct_id, flag_key, self.flag_result, flag_version
212
+ )
213
+
214
+ # Should be available immediately
215
+ result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
216
+ assert result is not None
217
+
218
+ # Wait for TTL to expire (1 second + buffer)
219
+ time.sleep(1.1)
220
+
221
+ # Should be expired
222
+ result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
223
+ assert result is None
224
+
225
+ def test_cache_version_invalidation(self):
226
+ distinct_id = "user123"
227
+ flag_key = "test-flag"
228
+ old_version = 1
229
+ new_version = 2
230
+
231
+ # Set flag with old version
232
+ self.cache.set_cached_flag(distinct_id, flag_key, self.flag_result, old_version)
233
+
234
+ # Should hit with old version
235
+ result = self.cache.get_cached_flag(distinct_id, flag_key, old_version)
236
+ assert result is not None
237
+
238
+ # Should miss with new version
239
+ result = self.cache.get_cached_flag(distinct_id, flag_key, new_version)
240
+ assert result is None
241
+
242
+ # Invalidate old version
243
+ self.cache.invalidate_version(old_version)
244
+
245
+ # Should miss even with old version after invalidation
246
+ result = self.cache.get_cached_flag(distinct_id, flag_key, old_version)
247
+ assert result is None
248
+
249
+ def test_stale_cache_functionality(self):
250
+ distinct_id = "user123"
251
+ flag_key = "test-flag"
252
+ flag_version = 1
253
+
254
+ # Set flag in cache
255
+ self.cache.set_cached_flag(
256
+ distinct_id, flag_key, self.flag_result, flag_version
257
+ )
258
+
259
+ # Wait for TTL to expire
260
+ time.sleep(1.1)
261
+
262
+ # Should not get fresh cache
263
+ result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
264
+ assert result is None
265
+
266
+ # Should get stale cache (within 1 hour default)
267
+ stale_result = self.cache.get_stale_cached_flag(distinct_id, flag_key)
268
+ assert stale_result is not None
269
+ assert stale_result.get_value()
270
+
271
+ def test_lru_eviction(self):
272
+ # Cache has max_size=3, so adding 4 users should evict the LRU one
273
+ flag_version = 1
274
+
275
+ # Add 3 users
276
+ for i in range(3):
277
+ user_id = f"user{i}"
278
+ self.cache.set_cached_flag(
279
+ user_id, "test-flag", self.flag_result, flag_version
280
+ )
281
+
282
+ # Access user0 to make it recently used
283
+ self.cache.get_cached_flag("user0", "test-flag", flag_version)
284
+
285
+ # Add 4th user, should evict user1 (least recently used)
286
+ self.cache.set_cached_flag("user3", "test-flag", self.flag_result, flag_version)
287
+
288
+ # user0 should still be there (was recently accessed)
289
+ result = self.cache.get_cached_flag("user0", "test-flag", flag_version)
290
+ assert result is not None
291
+
292
+ # user2 should still be there (was recently added)
293
+ result = self.cache.get_cached_flag("user2", "test-flag", flag_version)
294
+ assert result is not None
295
+
296
+ # user3 should be there (just added)
297
+ result = self.cache.get_cached_flag("user3", "test-flag", flag_version)
298
+ assert result is not None