posthoganalytics 6.6.0__tar.gz → 6.6.1__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 (57) hide show
  1. {posthoganalytics-6.6.0/posthoganalytics.egg-info → posthoganalytics-6.6.1}/PKG-INFO +1 -1
  2. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/client.py +15 -3
  3. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/feature_flags.py +169 -20
  4. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_feature_flags.py +428 -19
  5. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/version.py +1 -1
  6. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1/posthoganalytics.egg-info}/PKG-INFO +1 -1
  7. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/LICENSE +0 -0
  8. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/MANIFEST.in +0 -0
  9. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/README.md +0 -0
  10. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/__init__.py +0 -0
  11. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/__init__.py +0 -0
  12. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/anthropic/__init__.py +0 -0
  13. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/anthropic/anthropic.py +0 -0
  14. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/anthropic/anthropic_async.py +0 -0
  15. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/anthropic/anthropic_providers.py +0 -0
  16. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/gemini/__init__.py +0 -0
  17. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/gemini/gemini.py +0 -0
  18. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/langchain/__init__.py +0 -0
  19. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/langchain/callbacks.py +0 -0
  20. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/openai/__init__.py +0 -0
  21. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/openai/openai.py +0 -0
  22. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/openai/openai_async.py +0 -0
  23. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/openai/openai_providers.py +0 -0
  24. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/utils.py +0 -0
  25. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/args.py +0 -0
  26. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/consumer.py +0 -0
  27. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/contexts.py +0 -0
  28. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/exception_capture.py +0 -0
  29. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/exception_utils.py +0 -0
  30. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/integrations/__init__.py +0 -0
  31. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/integrations/django.py +0 -0
  32. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/poller.py +0 -0
  33. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/py.typed +0 -0
  34. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/request.py +0 -0
  35. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/test/__init__.py +0 -0
  36. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_before_send.py +0 -0
  37. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_client.py +0 -0
  38. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_consumer.py +0 -0
  39. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_contexts.py +0 -0
  40. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_exception_capture.py +0 -0
  41. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_feature_flag.py +0 -0
  42. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_feature_flag_result.py +0 -0
  43. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_module.py +0 -0
  44. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_request.py +0 -0
  45. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_size_limited_dict.py +0 -0
  46. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_types.py +0 -0
  47. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_utils.py +0 -0
  48. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/types.py +0 -0
  49. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics/utils.py +0 -0
  50. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics.egg-info/SOURCES.txt +0 -0
  51. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics.egg-info/dependency_links.txt +0 -0
  52. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics.egg-info/requires.txt +0 -0
  53. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/posthoganalytics.egg-info/top_level.txt +0 -0
  54. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/pyproject.toml +0 -0
  55. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/setup.cfg +0 -0
  56. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/setup.py +0 -0
  57. {posthoganalytics-6.6.0 → posthoganalytics-6.6.1}/setup_analytics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: posthoganalytics
3
- Version: 6.6.0
3
+ Version: 6.6.1
4
4
  Summary: Integrate PostHog into any python application.
5
5
  Home-page: https://github.com/posthog/posthog-python
6
6
  Author: Posthog
@@ -1204,6 +1204,9 @@ class Client(object):
1204
1204
  person_properties = person_properties or {}
1205
1205
  group_properties = group_properties or {}
1206
1206
 
1207
+ # Create evaluation cache for flag dependencies
1208
+ evaluation_cache: dict[str, Optional[FlagValue]] = {}
1209
+
1207
1210
  if feature_flag.get("ensure_experience_continuity", False):
1208
1211
  raise InconclusiveMatchError("Flag has experience continuity enabled")
1209
1212
 
@@ -1237,11 +1240,20 @@ class Client(object):
1237
1240
 
1238
1241
  focused_group_properties = group_properties[group_name]
1239
1242
  return match_feature_flag_properties(
1240
- feature_flag, groups[group_name], focused_group_properties
1243
+ feature_flag,
1244
+ groups[group_name],
1245
+ focused_group_properties,
1246
+ self.feature_flags_by_key,
1247
+ evaluation_cache,
1241
1248
  )
1242
1249
  else:
1243
1250
  return match_feature_flag_properties(
1244
- feature_flag, distinct_id, person_properties, self.cohorts
1251
+ feature_flag,
1252
+ distinct_id,
1253
+ person_properties,
1254
+ self.cohorts,
1255
+ self.feature_flags_by_key,
1256
+ evaluation_cache,
1245
1257
  )
1246
1258
 
1247
1259
  def feature_enabled(
@@ -1997,7 +2009,7 @@ class Client(object):
1997
2009
  for group_name in groups:
1998
2010
  all_group_properties[group_name] = {
1999
2011
  "$group_key": groups[group_name],
2000
- **(group_properties.get(group_name) or {}),
2012
+ **((group_properties or {}).get(group_name) or {}),
2001
2013
  }
2002
2014
 
2003
2015
  return all_person_properties, all_group_properties
@@ -55,8 +55,100 @@ def variant_lookup_table(feature_flag):
55
55
  return lookup_table
56
56
 
57
57
 
58
+ def evaluate_flag_dependency(
59
+ property, flags_by_key, evaluation_cache, distinct_id, properties, cohort_properties
60
+ ):
61
+ """
62
+ Evaluate a flag dependency property according to the dependency chain algorithm.
63
+
64
+ Args:
65
+ property: Flag property with type="flag" and dependency_chain
66
+ flags_by_key: Dictionary of all flags by their key
67
+ evaluation_cache: Cache for storing evaluation results
68
+ distinct_id: The distinct ID being evaluated
69
+ properties: Person properties for evaluation
70
+ cohort_properties: Cohort properties for evaluation
71
+
72
+ Returns:
73
+ bool: True if all dependencies in the chain evaluate to True, False otherwise
74
+ """
75
+ if flags_by_key is None or evaluation_cache is None:
76
+ # Cannot evaluate flag dependencies without required context
77
+ raise InconclusiveMatchError(
78
+ f"Cannot evaluate flag dependency on '{property.get('key', 'unknown')}' without flags_by_key and evaluation_cache"
79
+ )
80
+
81
+ # Check if dependency_chain is present - it should always be provided for flag dependencies
82
+ if "dependency_chain" not in property:
83
+ # Missing dependency_chain indicates malformed server data
84
+ raise InconclusiveMatchError(
85
+ f"Flag dependency property for '{property.get('key', 'unknown')}' is missing required 'dependency_chain' field"
86
+ )
87
+
88
+ dependency_chain = property["dependency_chain"]
89
+
90
+ # Handle circular dependency (empty chain means circular)
91
+ if len(dependency_chain) == 0:
92
+ log.debug(f"Circular dependency detected for flag: {property.get('key')}")
93
+ raise InconclusiveMatchError(
94
+ f"Circular dependency detected for flag '{property.get('key', 'unknown')}'"
95
+ )
96
+
97
+ # Evaluate all dependencies in the chain order
98
+ for dep_flag_key in dependency_chain:
99
+ if dep_flag_key not in evaluation_cache:
100
+ # Need to evaluate this dependency first
101
+ dep_flag = flags_by_key.get(dep_flag_key)
102
+ if not dep_flag:
103
+ # Missing flag dependency - cannot evaluate locally
104
+ evaluation_cache[dep_flag_key] = None
105
+ raise InconclusiveMatchError(
106
+ f"Cannot evaluate flag dependency '{dep_flag_key}' - flag not found in local flags"
107
+ )
108
+ else:
109
+ # Check if the flag is active (same check as in client._compute_flag_locally)
110
+ if not dep_flag.get("active"):
111
+ evaluation_cache[dep_flag_key] = False
112
+ else:
113
+ # Recursively evaluate the dependency
114
+ try:
115
+ dep_result = match_feature_flag_properties(
116
+ dep_flag,
117
+ distinct_id,
118
+ properties,
119
+ cohort_properties,
120
+ flags_by_key,
121
+ evaluation_cache,
122
+ )
123
+ evaluation_cache[dep_flag_key] = dep_result
124
+ except InconclusiveMatchError as e:
125
+ # If we can't evaluate a dependency, store None and propagate the error
126
+ evaluation_cache[dep_flag_key] = None
127
+ raise InconclusiveMatchError(
128
+ f"Cannot evaluate flag dependency '{dep_flag_key}': {e}"
129
+ ) from e
130
+
131
+ # Check the cached result
132
+ cached_result = evaluation_cache[dep_flag_key]
133
+ if cached_result is None:
134
+ # Previously inconclusive - raise error again
135
+ raise InconclusiveMatchError(
136
+ f"Flag dependency '{dep_flag_key}' was previously inconclusive"
137
+ )
138
+ elif not cached_result:
139
+ # Definitive False result - dependency failed
140
+ return False
141
+
142
+ return True
143
+
144
+
58
145
  def match_feature_flag_properties(
59
- flag, distinct_id, properties, cohort_properties=None
146
+ flag,
147
+ distinct_id,
148
+ properties,
149
+ cohort_properties=None,
150
+ flags_by_key=None,
151
+ evaluation_cache=None,
60
152
  ) -> FlagValue:
61
153
  flag_conditions = (flag.get("filters") or {}).get("groups") or []
62
154
  is_inconclusive = False
@@ -79,7 +171,13 @@ def match_feature_flag_properties(
79
171
  # if any one condition resolves to True, we can shortcircuit and return
80
172
  # the matching variant
81
173
  if is_condition_match(
82
- flag, distinct_id, condition, properties, cohort_properties
174
+ flag,
175
+ distinct_id,
176
+ condition,
177
+ properties,
178
+ cohort_properties,
179
+ flags_by_key,
180
+ evaluation_cache,
83
181
  ):
84
182
  variant_override = condition.get("variant")
85
183
  if variant_override and variant_override in valid_variant_keys:
@@ -101,22 +199,36 @@ def match_feature_flag_properties(
101
199
 
102
200
 
103
201
  def is_condition_match(
104
- feature_flag, distinct_id, condition, properties, cohort_properties
202
+ feature_flag,
203
+ distinct_id,
204
+ condition,
205
+ properties,
206
+ cohort_properties,
207
+ flags_by_key=None,
208
+ evaluation_cache=None,
105
209
  ) -> bool:
106
210
  rollout_percentage = condition.get("rollout_percentage")
107
211
  if len(condition.get("properties") or []) > 0:
108
212
  for prop in condition.get("properties"):
109
213
  property_type = prop.get("type")
110
214
  if property_type == "cohort":
111
- matches = match_cohort(prop, properties, cohort_properties)
215
+ matches = match_cohort(
216
+ prop,
217
+ properties,
218
+ cohort_properties,
219
+ flags_by_key,
220
+ evaluation_cache,
221
+ distinct_id,
222
+ )
112
223
  elif property_type == "flag":
113
- log.warning(
114
- "Flag dependency filters are not supported in local evaluation. "
115
- "Skipping condition for flag '%s' with dependency on flag '%s'",
116
- feature_flag.get("key", "unknown"),
117
- prop.get("key", "unknown"),
224
+ matches = evaluate_flag_dependency(
225
+ prop,
226
+ flags_by_key,
227
+ evaluation_cache,
228
+ distinct_id,
229
+ properties,
230
+ cohort_properties,
118
231
  )
119
- continue
120
232
  else:
121
233
  matches = match_property(prop, properties)
122
234
  if not matches:
@@ -264,7 +376,14 @@ def match_property(property, property_values) -> bool:
264
376
  raise InconclusiveMatchError(f"Unknown operator {operator}")
265
377
 
266
378
 
267
- def match_cohort(property, property_values, cohort_properties) -> bool:
379
+ def match_cohort(
380
+ property,
381
+ property_values,
382
+ cohort_properties,
383
+ flags_by_key=None,
384
+ evaluation_cache=None,
385
+ distinct_id=None,
386
+ ) -> bool:
268
387
  # Cohort properties are in the form of property groups like this:
269
388
  # {
270
389
  # "cohort_id": {
@@ -281,10 +400,24 @@ def match_cohort(property, property_values, cohort_properties) -> bool:
281
400
  )
282
401
 
283
402
  property_group = cohort_properties[cohort_id]
284
- return match_property_group(property_group, property_values, cohort_properties)
403
+ return match_property_group(
404
+ property_group,
405
+ property_values,
406
+ cohort_properties,
407
+ flags_by_key,
408
+ evaluation_cache,
409
+ distinct_id,
410
+ )
285
411
 
286
412
 
287
- def match_property_group(property_group, property_values, cohort_properties) -> bool:
413
+ def match_property_group(
414
+ property_group,
415
+ property_values,
416
+ cohort_properties,
417
+ flags_by_key=None,
418
+ evaluation_cache=None,
419
+ distinct_id=None,
420
+ ) -> bool:
288
421
  if not property_group:
289
422
  return True
290
423
 
@@ -301,7 +434,14 @@ def match_property_group(property_group, property_values, cohort_properties) ->
301
434
  # a nested property group
302
435
  for prop in properties:
303
436
  try:
304
- matches = match_property_group(prop, property_values, cohort_properties)
437
+ matches = match_property_group(
438
+ prop,
439
+ property_values,
440
+ cohort_properties,
441
+ flags_by_key,
442
+ evaluation_cache,
443
+ distinct_id,
444
+ )
305
445
  if property_group_type == "AND":
306
446
  if not matches:
307
447
  return False
@@ -324,14 +464,23 @@ def match_property_group(property_group, property_values, cohort_properties) ->
324
464
  for prop in properties:
325
465
  try:
326
466
  if prop.get("type") == "cohort":
327
- matches = match_cohort(prop, property_values, cohort_properties)
467
+ matches = match_cohort(
468
+ prop,
469
+ property_values,
470
+ cohort_properties,
471
+ flags_by_key,
472
+ evaluation_cache,
473
+ distinct_id,
474
+ )
328
475
  elif prop.get("type") == "flag":
329
- log.warning(
330
- "Flag dependency filters are not supported in local evaluation. "
331
- "Skipping condition with dependency on flag '%s'",
332
- prop.get("key", "unknown"),
476
+ matches = evaluate_flag_dependency(
477
+ prop,
478
+ flags_by_key,
479
+ evaluation_cache,
480
+ distinct_id,
481
+ property_values,
482
+ cohort_properties,
333
483
  )
334
- continue
335
484
  else:
336
485
  matches = match_property(prop, property_values)
337
486
 
@@ -1361,6 +1361,8 @@ class TestLocalEvaluation(unittest.TestCase):
1361
1361
  def test_feature_flags_with_flag_dependencies(
1362
1362
  self, patch_get, patch_flags, mock_log
1363
1363
  ):
1364
+ # Mock remote flags call to return empty for this flag (fallback returns None)
1365
+ patch_flags.return_value = {"featureFlags": {}}
1364
1366
  client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
1365
1367
  client.feature_flags = [
1366
1368
  {
@@ -1377,6 +1379,7 @@ class TestLocalEvaluation(unittest.TestCase):
1377
1379
  "operator": "exact",
1378
1380
  "value": True,
1379
1381
  "type": "flag",
1382
+ "dependency_chain": ["beta-feature"],
1380
1383
  },
1381
1384
  {
1382
1385
  "key": "email",
@@ -1392,39 +1395,445 @@ class TestLocalEvaluation(unittest.TestCase):
1392
1395
  }
1393
1396
  ]
1394
1397
 
1395
- # Test that flag evaluation doesn't fail when encountering a flag dependency
1396
- # The flag should evaluate based on other conditions (email contains @example.com)
1397
- # Since flag dependencies aren't implemented, it should skip the flag condition
1398
- # and evaluate based on the email condition only
1398
+ # Test that flag evaluation handles flag dependencies properly
1399
+ # The flag has a dependency on "beta-feature" which doesn't exist locally
1400
+ # Since the dependency doesn't exist, local evaluation should fail and fall back to remote
1401
+ # Remote returns empty result, so final result is None
1399
1402
  feature_flag_match = client.get_feature_flag(
1400
1403
  "flag-with-dependencies",
1401
1404
  "test-user",
1402
1405
  person_properties={"email": "test@example.com"},
1403
1406
  )
1404
- self.assertEqual(feature_flag_match, True)
1405
- self.assertEqual(patch_flags.call_count, 0)
1407
+ self.assertIsNone(feature_flag_match)
1408
+ self.assertEqual(patch_flags.call_count, 1)
1406
1409
  self.assertEqual(patch_get.call_count, 0)
1407
1410
 
1408
- # Verify warning was logged for flag dependency
1409
- mock_log.warning.assert_called_with(
1410
- "Flag dependency filters are not supported in local evaluation. "
1411
- "Skipping condition for flag '%s' with dependency on flag '%s'",
1412
- "flag-with-dependencies",
1413
- "beta-feature",
1414
- )
1415
-
1416
- # Test with email that doesn't match
1411
+ # Test with email that doesn't match (should also fall back to remote due to missing dependency)
1417
1412
  feature_flag_match = client.get_feature_flag(
1418
1413
  "flag-with-dependencies",
1419
1414
  "test-user-2",
1420
1415
  person_properties={"email": "test@other.com"},
1421
1416
  )
1422
- self.assertEqual(feature_flag_match, False)
1423
- self.assertEqual(patch_flags.call_count, 0)
1417
+ self.assertIsNone(feature_flag_match)
1418
+ self.assertEqual(patch_flags.call_count, 2) # Called twice now
1424
1419
  self.assertEqual(patch_get.call_count, 0)
1425
1420
 
1426
- # Verify warning was logged again for the second evaluation
1427
- self.assertEqual(mock_log.warning.call_count, 2)
1421
+ @mock.patch("posthog.client.flags")
1422
+ @mock.patch("posthog.client.get")
1423
+ def test_flag_dependencies_simple_chain(self, patch_get, patch_flags):
1424
+ """Test basic flag dependency: flag-b depends on flag-a"""
1425
+ client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
1426
+ client.feature_flags = [
1427
+ {
1428
+ "id": 1,
1429
+ "name": "Flag A",
1430
+ "key": "flag-a",
1431
+ "active": True,
1432
+ "filters": {
1433
+ "groups": [
1434
+ {
1435
+ "properties": [
1436
+ {
1437
+ "key": "email",
1438
+ "operator": "icontains",
1439
+ "value": "@example.com",
1440
+ "type": "person",
1441
+ }
1442
+ ],
1443
+ "rollout_percentage": 100,
1444
+ }
1445
+ ],
1446
+ },
1447
+ },
1448
+ {
1449
+ "id": 2,
1450
+ "name": "Flag B",
1451
+ "key": "flag-b",
1452
+ "active": True,
1453
+ "filters": {
1454
+ "groups": [
1455
+ {
1456
+ "properties": [
1457
+ {
1458
+ "key": "flag-a",
1459
+ "operator": "exact",
1460
+ "value": True,
1461
+ "type": "flag",
1462
+ "dependency_chain": ["flag-a"],
1463
+ }
1464
+ ],
1465
+ "rollout_percentage": 100,
1466
+ }
1467
+ ],
1468
+ },
1469
+ },
1470
+ ]
1471
+
1472
+ # Test when dependency is satisfied
1473
+ result = client.get_feature_flag(
1474
+ "flag-b",
1475
+ "test-user",
1476
+ person_properties={"email": "test@example.com"},
1477
+ )
1478
+ self.assertEqual(result, True)
1479
+
1480
+ # Test when dependency is not satisfied
1481
+ result = client.get_feature_flag(
1482
+ "flag-b",
1483
+ "test-user-2",
1484
+ person_properties={"email": "test@other.com"},
1485
+ )
1486
+ self.assertEqual(result, False)
1487
+
1488
+ @mock.patch("posthog.client.flags")
1489
+ @mock.patch("posthog.client.get")
1490
+ def test_flag_dependencies_circular_dependency(self, patch_get, patch_flags):
1491
+ """Test circular dependency handling: flag-a depends on flag-b, flag-b depends on flag-a"""
1492
+ # Mock remote flags call to return empty for these flags (fallback returns None)
1493
+ patch_flags.return_value = {"featureFlags": {}}
1494
+ client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
1495
+ client.feature_flags = [
1496
+ {
1497
+ "id": 1,
1498
+ "name": "Flag A",
1499
+ "key": "flag-a",
1500
+ "active": True,
1501
+ "filters": {
1502
+ "groups": [
1503
+ {
1504
+ "properties": [
1505
+ {
1506
+ "key": "flag-b",
1507
+ "operator": "exact",
1508
+ "value": True,
1509
+ "type": "flag",
1510
+ "dependency_chain": [], # Empty chain indicates circular dependency
1511
+ }
1512
+ ],
1513
+ "rollout_percentage": 100,
1514
+ }
1515
+ ],
1516
+ },
1517
+ },
1518
+ {
1519
+ "id": 2,
1520
+ "name": "Flag B",
1521
+ "key": "flag-b",
1522
+ "active": True,
1523
+ "filters": {
1524
+ "groups": [
1525
+ {
1526
+ "properties": [
1527
+ {
1528
+ "key": "flag-a",
1529
+ "operator": "exact",
1530
+ "value": True,
1531
+ "type": "flag",
1532
+ "dependency_chain": [], # Empty chain indicates circular dependency
1533
+ }
1534
+ ],
1535
+ "rollout_percentage": 100,
1536
+ }
1537
+ ],
1538
+ },
1539
+ },
1540
+ ]
1541
+
1542
+ # Both flags should fall back to remote evaluation due to circular dependency
1543
+ # Since we're not mocking the remote call, both should return None
1544
+ result_a = client.get_feature_flag("flag-a", "test-user")
1545
+ self.assertIsNone(result_a)
1546
+
1547
+ result_b = client.get_feature_flag("flag-b", "test-user")
1548
+ self.assertIsNone(result_b)
1549
+
1550
+ @mock.patch("posthog.client.flags")
1551
+ @mock.patch("posthog.client.get")
1552
+ def test_flag_dependencies_missing_flag(self, patch_get, patch_flags):
1553
+ """Test handling of missing flag dependency"""
1554
+ # Mock remote flags call to return empty for this flag (fallback returns None)
1555
+ patch_flags.return_value = {"featureFlags": {}}
1556
+ client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
1557
+ client.feature_flags = [
1558
+ {
1559
+ "id": 1,
1560
+ "name": "Flag A",
1561
+ "key": "flag-a",
1562
+ "active": True,
1563
+ "filters": {
1564
+ "groups": [
1565
+ {
1566
+ "properties": [
1567
+ {
1568
+ "key": "non-existent-flag",
1569
+ "operator": "exact",
1570
+ "value": True,
1571
+ "type": "flag",
1572
+ "dependency_chain": ["non-existent-flag"],
1573
+ }
1574
+ ],
1575
+ "rollout_percentage": 100,
1576
+ }
1577
+ ],
1578
+ },
1579
+ }
1580
+ ]
1581
+
1582
+ # Should fall back to remote evaluation because dependency doesn't exist
1583
+ # Since we're not mocking the remote call, should return None
1584
+ result = client.get_feature_flag("flag-a", "test-user")
1585
+ self.assertIsNone(result)
1586
+
1587
+ @mock.patch("posthog.client.flags")
1588
+ @mock.patch("posthog.client.get")
1589
+ def test_flag_dependencies_complex_chain(self, patch_get, patch_flags):
1590
+ """Test complex dependency chain: flag-d -> flag-c -> [flag-a, flag-b]"""
1591
+ client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
1592
+ client.feature_flags = [
1593
+ {
1594
+ "id": 1,
1595
+ "name": "Flag A",
1596
+ "key": "flag-a",
1597
+ "active": True,
1598
+ "filters": {
1599
+ "groups": [
1600
+ {
1601
+ "properties": [],
1602
+ "rollout_percentage": 100,
1603
+ }
1604
+ ],
1605
+ },
1606
+ },
1607
+ {
1608
+ "id": 2,
1609
+ "name": "Flag B",
1610
+ "key": "flag-b",
1611
+ "active": True,
1612
+ "filters": {
1613
+ "groups": [
1614
+ {
1615
+ "properties": [],
1616
+ "rollout_percentage": 100,
1617
+ }
1618
+ ],
1619
+ },
1620
+ },
1621
+ {
1622
+ "id": 3,
1623
+ "name": "Flag C",
1624
+ "key": "flag-c",
1625
+ "active": True,
1626
+ "filters": {
1627
+ "groups": [
1628
+ {
1629
+ "properties": [
1630
+ {
1631
+ "key": "flag-a",
1632
+ "operator": "exact",
1633
+ "value": True,
1634
+ "type": "flag",
1635
+ "dependency_chain": ["flag-a"],
1636
+ },
1637
+ {
1638
+ "key": "flag-b",
1639
+ "operator": "exact",
1640
+ "value": True,
1641
+ "type": "flag",
1642
+ "dependency_chain": ["flag-b"],
1643
+ },
1644
+ ],
1645
+ "rollout_percentage": 100,
1646
+ }
1647
+ ],
1648
+ },
1649
+ },
1650
+ {
1651
+ "id": 4,
1652
+ "name": "Flag D",
1653
+ "key": "flag-d",
1654
+ "active": True,
1655
+ "filters": {
1656
+ "groups": [
1657
+ {
1658
+ "properties": [
1659
+ {
1660
+ "key": "flag-c",
1661
+ "operator": "exact",
1662
+ "value": True,
1663
+ "type": "flag",
1664
+ "dependency_chain": ["flag-a", "flag-b", "flag-c"],
1665
+ }
1666
+ ],
1667
+ "rollout_percentage": 100,
1668
+ }
1669
+ ],
1670
+ },
1671
+ },
1672
+ ]
1673
+
1674
+ # All dependencies satisfied - should return True
1675
+ result = client.get_feature_flag("flag-d", "test-user")
1676
+ self.assertEqual(result, True)
1677
+
1678
+ # Make flag-a inactive - should break the chain
1679
+ client.feature_flags[0]["active"] = False
1680
+ result = client.get_feature_flag("flag-d", "test-user")
1681
+ self.assertEqual(result, False)
1682
+
1683
+ @mock.patch("posthog.client.flags")
1684
+ @mock.patch("posthog.client.get")
1685
+ def test_flag_dependencies_mixed_conditions(self, patch_get, patch_flags):
1686
+ """Test flag dependency mixed with other property conditions"""
1687
+ client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
1688
+ client.feature_flags = [
1689
+ {
1690
+ "id": 1,
1691
+ "name": "Base Flag",
1692
+ "key": "base-flag",
1693
+ "active": True,
1694
+ "filters": {
1695
+ "groups": [
1696
+ {
1697
+ "properties": [],
1698
+ "rollout_percentage": 100,
1699
+ }
1700
+ ],
1701
+ },
1702
+ },
1703
+ {
1704
+ "id": 2,
1705
+ "name": "Mixed Flag",
1706
+ "key": "mixed-flag",
1707
+ "active": True,
1708
+ "filters": {
1709
+ "groups": [
1710
+ {
1711
+ "properties": [
1712
+ {
1713
+ "key": "base-flag",
1714
+ "operator": "exact",
1715
+ "value": True,
1716
+ "type": "flag",
1717
+ "dependency_chain": ["base-flag"],
1718
+ },
1719
+ {
1720
+ "key": "email",
1721
+ "operator": "icontains",
1722
+ "value": "@example.com",
1723
+ "type": "person",
1724
+ },
1725
+ ],
1726
+ "rollout_percentage": 100,
1727
+ }
1728
+ ],
1729
+ },
1730
+ },
1731
+ ]
1732
+
1733
+ # Both flag dependency and email condition satisfied
1734
+ result = client.get_feature_flag(
1735
+ "mixed-flag",
1736
+ "test-user",
1737
+ person_properties={"email": "test@example.com"},
1738
+ )
1739
+ self.assertEqual(result, True)
1740
+
1741
+ # Flag dependency satisfied but email condition not satisfied
1742
+ result = client.get_feature_flag(
1743
+ "mixed-flag",
1744
+ "test-user-2",
1745
+ person_properties={"email": "test@other.com"},
1746
+ )
1747
+ self.assertEqual(result, False)
1748
+
1749
+ # Email condition satisfied but flag dependency not satisfied
1750
+ client.feature_flags[0]["active"] = False
1751
+ result = client.get_feature_flag(
1752
+ "mixed-flag",
1753
+ "test-user-3",
1754
+ person_properties={"email": "test@example.com"},
1755
+ )
1756
+ self.assertEqual(result, False)
1757
+
1758
+ @mock.patch("posthog.client.flags")
1759
+ @mock.patch("posthog.client.get")
1760
+ def test_flag_dependencies_malformed_chain(self, patch_get, patch_flags):
1761
+ """Test handling of malformed dependency chains"""
1762
+ # Mock remote flags call to return empty for this flag (fallback returns None)
1763
+ patch_flags.return_value = {"featureFlags": {}}
1764
+ client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
1765
+ client.feature_flags = [
1766
+ {
1767
+ "id": 1,
1768
+ "name": "Base Flag",
1769
+ "key": "base-flag",
1770
+ "active": True,
1771
+ "filters": {
1772
+ "groups": [
1773
+ {
1774
+ "properties": [],
1775
+ "rollout_percentage": 100,
1776
+ }
1777
+ ],
1778
+ },
1779
+ },
1780
+ {
1781
+ "id": 2,
1782
+ "name": "Missing Chain Flag",
1783
+ "key": "missing-chain-flag",
1784
+ "active": True,
1785
+ "filters": {
1786
+ "groups": [
1787
+ {
1788
+ "properties": [
1789
+ {
1790
+ "key": "base-flag",
1791
+ "operator": "exact",
1792
+ "value": True,
1793
+ "type": "flag",
1794
+ # No dependency_chain property - should handle gracefully
1795
+ }
1796
+ ],
1797
+ "rollout_percentage": 100,
1798
+ }
1799
+ ],
1800
+ },
1801
+ },
1802
+ ]
1803
+
1804
+ # Should fall back to remote evaluation when dependency_chain is missing
1805
+ # Since we're not mocking the remote call, should return None
1806
+ result = client.get_feature_flag("missing-chain-flag", "test-user")
1807
+ self.assertIsNone(result)
1808
+
1809
+ def test_flag_dependencies_without_context_raises_inconclusive(self):
1810
+ """Test that missing flags_by_key raises InconclusiveMatchError"""
1811
+ from posthoganalytics.feature_flags import (
1812
+ evaluate_flag_dependency,
1813
+ InconclusiveMatchError,
1814
+ )
1815
+
1816
+ property_with_flag_dep = {
1817
+ "key": "some-flag",
1818
+ "operator": "exact",
1819
+ "value": True,
1820
+ "type": "flag",
1821
+ "dependency_chain": ["some-flag"],
1822
+ }
1823
+
1824
+ # Should raise InconclusiveMatchError when flags_by_key is None
1825
+ with self.assertRaises(InconclusiveMatchError) as cm:
1826
+ evaluate_flag_dependency(
1827
+ property_with_flag_dep,
1828
+ flags_by_key=None, # This should trigger the error
1829
+ evaluation_cache={},
1830
+ distinct_id="test-user",
1831
+ properties={},
1832
+ cohort_properties={},
1833
+ )
1834
+
1835
+ self.assertIn("Cannot evaluate flag dependency", str(cm.exception))
1836
+ self.assertIn("some-flag", str(cm.exception))
1428
1837
 
1429
1838
  @mock.patch("posthog.client.Poller")
1430
1839
  @mock.patch("posthog.client.get")
@@ -1,4 +1,4 @@
1
- VERSION = "6.6.0"
1
+ VERSION = "6.6.1"
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: posthoganalytics
3
- Version: 6.6.0
3
+ Version: 6.6.1
4
4
  Summary: Integrate PostHog into any python application.
5
5
  Home-page: https://github.com/posthog/posthog-python
6
6
  Author: Posthog