posthoganalytics 6.5.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.5.0/posthoganalytics.egg-info → posthoganalytics-6.6.1}/PKG-INFO +1 -1
  2. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/client.py +87 -17
  3. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/feature_flags.py +169 -20
  4. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_client.py +56 -0
  5. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_feature_flags.py +546 -26
  6. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/types.py +3 -0
  7. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/version.py +1 -1
  8. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1/posthoganalytics.egg-info}/PKG-INFO +1 -1
  9. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/LICENSE +0 -0
  10. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/MANIFEST.in +0 -0
  11. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/README.md +0 -0
  12. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/__init__.py +0 -0
  13. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/__init__.py +0 -0
  14. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/anthropic/__init__.py +0 -0
  15. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/anthropic/anthropic.py +0 -0
  16. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/anthropic/anthropic_async.py +0 -0
  17. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/anthropic/anthropic_providers.py +0 -0
  18. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/gemini/__init__.py +0 -0
  19. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/gemini/gemini.py +0 -0
  20. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/langchain/__init__.py +0 -0
  21. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/langchain/callbacks.py +0 -0
  22. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/openai/__init__.py +0 -0
  23. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/openai/openai.py +0 -0
  24. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/openai/openai_async.py +0 -0
  25. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/openai/openai_providers.py +0 -0
  26. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/ai/utils.py +0 -0
  27. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/args.py +0 -0
  28. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/consumer.py +0 -0
  29. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/contexts.py +0 -0
  30. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/exception_capture.py +0 -0
  31. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/exception_utils.py +0 -0
  32. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/integrations/__init__.py +0 -0
  33. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/integrations/django.py +0 -0
  34. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/poller.py +0 -0
  35. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/py.typed +0 -0
  36. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/request.py +0 -0
  37. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/test/__init__.py +0 -0
  38. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_before_send.py +0 -0
  39. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_consumer.py +0 -0
  40. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_contexts.py +0 -0
  41. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_exception_capture.py +0 -0
  42. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_feature_flag.py +0 -0
  43. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_feature_flag_result.py +0 -0
  44. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_module.py +0 -0
  45. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_request.py +0 -0
  46. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_size_limited_dict.py +0 -0
  47. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_types.py +0 -0
  48. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/test/test_utils.py +0 -0
  49. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics/utils.py +0 -0
  50. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics.egg-info/SOURCES.txt +0 -0
  51. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics.egg-info/dependency_links.txt +0 -0
  52. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics.egg-info/requires.txt +0 -0
  53. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/posthoganalytics.egg-info/top_level.txt +0 -0
  54. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/pyproject.toml +0 -0
  55. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/setup.cfg +0 -0
  56. {posthoganalytics-6.5.0 → posthoganalytics-6.6.1}/setup.py +0 -0
  57. {posthoganalytics-6.5.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.5.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
@@ -44,6 +44,7 @@ from posthoganalytics.types import (
44
44
  FlagsAndPayloads,
45
45
  FlagsResponse,
46
46
  FlagValue,
47
+ SendFeatureFlagsOptions,
47
48
  normalize_flags_response,
48
49
  to_flags_and_payloads,
49
50
  to_payloads,
@@ -313,6 +314,7 @@ class Client(object):
313
314
  person_properties=None,
314
315
  group_properties=None,
315
316
  disable_geoip=None,
317
+ flag_keys_to_evaluate: Optional[list[str]] = None,
316
318
  ) -> dict[str, Union[bool, str]]:
317
319
  """
318
320
  Get feature flag variants for a user by calling decide.
@@ -323,12 +325,19 @@ class Client(object):
323
325
  person_properties: A dictionary of person properties.
324
326
  group_properties: A dictionary of group properties.
325
327
  disable_geoip: Whether to disable GeoIP for this request.
328
+ flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided,
329
+ only these flags will be evaluated, improving performance.
326
330
 
327
331
  Category:
328
332
  Feature Flags
329
333
  """
330
334
  resp_data = self.get_flags_decision(
331
- distinct_id, groups, person_properties, group_properties, disable_geoip
335
+ distinct_id,
336
+ groups,
337
+ person_properties,
338
+ group_properties,
339
+ disable_geoip,
340
+ flag_keys_to_evaluate,
332
341
  )
333
342
  return to_values(resp_data) or {}
334
343
 
@@ -339,6 +348,7 @@ class Client(object):
339
348
  person_properties=None,
340
349
  group_properties=None,
341
350
  disable_geoip=None,
351
+ flag_keys_to_evaluate: Optional[list[str]] = None,
342
352
  ) -> dict[str, str]:
343
353
  """
344
354
  Get feature flag payloads for a user by calling decide.
@@ -349,6 +359,8 @@ class Client(object):
349
359
  person_properties: A dictionary of person properties.
350
360
  group_properties: A dictionary of group properties.
351
361
  disable_geoip: Whether to disable GeoIP for this request.
362
+ flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided,
363
+ only these flags will be evaluated, improving performance.
352
364
 
353
365
  Examples:
354
366
  ```python
@@ -359,7 +371,12 @@ class Client(object):
359
371
  Feature Flags
360
372
  """
361
373
  resp_data = self.get_flags_decision(
362
- distinct_id, groups, person_properties, group_properties, disable_geoip
374
+ distinct_id,
375
+ groups,
376
+ person_properties,
377
+ group_properties,
378
+ disable_geoip,
379
+ flag_keys_to_evaluate,
363
380
  )
364
381
  return to_payloads(resp_data) or {}
365
382
 
@@ -370,6 +387,7 @@ class Client(object):
370
387
  person_properties=None,
371
388
  group_properties=None,
372
389
  disable_geoip=None,
390
+ flag_keys_to_evaluate: Optional[list[str]] = None,
373
391
  ) -> FlagsAndPayloads:
374
392
  """
375
393
  Get feature flags and payloads for a user by calling decide.
@@ -380,6 +398,8 @@ class Client(object):
380
398
  person_properties: A dictionary of person properties.
381
399
  group_properties: A dictionary of group properties.
382
400
  disable_geoip: Whether to disable GeoIP for this request.
401
+ flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided,
402
+ only these flags will be evaluated, improving performance.
383
403
 
384
404
  Examples:
385
405
  ```python
@@ -390,7 +410,12 @@ class Client(object):
390
410
  Feature Flags
391
411
  """
392
412
  resp = self.get_flags_decision(
393
- distinct_id, groups, person_properties, group_properties, disable_geoip
413
+ distinct_id,
414
+ groups,
415
+ person_properties,
416
+ group_properties,
417
+ disable_geoip,
418
+ flag_keys_to_evaluate,
394
419
  )
395
420
  return to_flags_and_payloads(resp)
396
421
 
@@ -401,6 +426,7 @@ class Client(object):
401
426
  person_properties=None,
402
427
  group_properties=None,
403
428
  disable_geoip=None,
429
+ flag_keys_to_evaluate: Optional[list[str]] = None,
404
430
  ) -> FlagsResponse:
405
431
  """
406
432
  Get feature flags decision.
@@ -411,6 +437,8 @@ class Client(object):
411
437
  person_properties: A dictionary of person properties.
412
438
  group_properties: A dictionary of group properties.
413
439
  disable_geoip: Whether to disable GeoIP for this request.
440
+ flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided,
441
+ only these flags will be evaluated, improving performance.
414
442
 
415
443
  Examples:
416
444
  ```python
@@ -441,6 +469,9 @@ class Client(object):
441
469
  "geoip_disable": disable_geoip,
442
470
  }
443
471
 
472
+ if flag_keys_to_evaluate:
473
+ request_data["flag_keys_to_evaluate"] = flag_keys_to_evaluate
474
+
444
475
  resp_data = flags(
445
476
  self.api_key,
446
477
  self.host,
@@ -545,6 +576,7 @@ class Client(object):
545
576
  group_properties=flag_options["group_properties"],
546
577
  disable_geoip=disable_geoip,
547
578
  only_evaluate_locally=True,
579
+ flag_keys_to_evaluate=flag_options["flag_keys_filter"],
548
580
  )
549
581
  else:
550
582
  # Default behavior - use remote evaluation
@@ -554,6 +586,7 @@ class Client(object):
554
586
  person_properties=flag_options["person_properties"],
555
587
  group_properties=flag_options["group_properties"],
556
588
  disable_geoip=disable_geoip,
589
+ flag_keys_to_evaluate=flag_options["flag_keys_filter"],
557
590
  )
558
591
  except Exception as e:
559
592
  self.log.exception(
@@ -586,7 +619,7 @@ class Client(object):
586
619
 
587
620
  return self._enqueue(msg, disable_geoip)
588
621
 
589
- def _parse_send_feature_flags(self, send_feature_flags) -> dict:
622
+ def _parse_send_feature_flags(self, send_feature_flags) -> SendFeatureFlagsOptions:
590
623
  """
591
624
  Parse and normalize send_feature_flags parameter into a standard format.
592
625
 
@@ -594,8 +627,8 @@ class Client(object):
594
627
  send_feature_flags: Either bool or SendFeatureFlagsOptions dict
595
628
 
596
629
  Returns:
597
- dict: Normalized options with keys: should_send, only_evaluate_locally,
598
- person_properties, group_properties
630
+ SendFeatureFlagsOptions: Normalized options with keys: should_send, only_evaluate_locally,
631
+ person_properties, group_properties, flag_keys_filter
599
632
 
600
633
  Raises:
601
634
  TypeError: If send_feature_flags is not bool or dict
@@ -608,6 +641,7 @@ class Client(object):
608
641
  ),
609
642
  "person_properties": send_feature_flags.get("person_properties"),
610
643
  "group_properties": send_feature_flags.get("group_properties"),
644
+ "flag_keys_filter": send_feature_flags.get("flag_keys_filter"),
611
645
  }
612
646
  elif isinstance(send_feature_flags, bool):
613
647
  return {
@@ -615,6 +649,7 @@ class Client(object):
615
649
  "only_evaluate_locally": None,
616
650
  "person_properties": None,
617
651
  "group_properties": None,
652
+ "flag_keys_filter": None,
618
653
  }
619
654
  else:
620
655
  raise TypeError(
@@ -1169,6 +1204,9 @@ class Client(object):
1169
1204
  person_properties = person_properties or {}
1170
1205
  group_properties = group_properties or {}
1171
1206
 
1207
+ # Create evaluation cache for flag dependencies
1208
+ evaluation_cache: dict[str, Optional[FlagValue]] = {}
1209
+
1172
1210
  if feature_flag.get("ensure_experience_continuity", False):
1173
1211
  raise InconclusiveMatchError("Flag has experience continuity enabled")
1174
1212
 
@@ -1184,12 +1222,12 @@ class Client(object):
1184
1222
  self.log.warning(
1185
1223
  f"[FEATURE FLAGS] Unknown group type index {aggregation_group_type_index} for feature flag {feature_flag['key']}"
1186
1224
  )
1187
- # failover to `/decide/`
1225
+ # failover to `/flags`
1188
1226
  raise InconclusiveMatchError("Flag has unknown group type index")
1189
1227
 
1190
1228
  if group_name not in groups:
1191
1229
  # Group flags are never enabled in `groups` aren't passed in
1192
- # don't failover to `/decide/`, since response will be the same
1230
+ # don't failover to `/flags`, since response will be the same
1193
1231
  if warn_on_unknown_groups:
1194
1232
  self.log.warning(
1195
1233
  f"[FEATURE FLAGS] Can't compute group feature flag: {feature_flag['key']} without group names passed in"
@@ -1202,11 +1240,20 @@ class Client(object):
1202
1240
 
1203
1241
  focused_group_properties = group_properties[group_name]
1204
1242
  return match_feature_flag_properties(
1205
- 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,
1206
1248
  )
1207
1249
  else:
1208
1250
  return match_feature_flag_properties(
1209
- 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,
1210
1257
  )
1211
1258
 
1212
1259
  def feature_enabled(
@@ -1317,7 +1364,7 @@ class Client(object):
1317
1364
  )
1318
1365
  elif not only_evaluate_locally:
1319
1366
  try:
1320
- flag_details, request_id = self._get_feature_flag_details_from_decide(
1367
+ flag_details, request_id = self._get_feature_flag_details_from_server(
1321
1368
  key,
1322
1369
  distinct_id,
1323
1370
  groups,
@@ -1557,7 +1604,7 @@ class Client(object):
1557
1604
  )
1558
1605
  return feature_flag_result.payload if feature_flag_result else None
1559
1606
 
1560
- def _get_feature_flag_details_from_decide(
1607
+ def _get_feature_flag_details_from_server(
1561
1608
  self,
1562
1609
  key: str,
1563
1610
  distinct_id: ID_TYPES,
@@ -1567,10 +1614,15 @@ class Client(object):
1567
1614
  disable_geoip: Optional[bool],
1568
1615
  ) -> tuple[Optional[FeatureFlag], Optional[str]]:
1569
1616
  """
1570
- Calls /decide and returns the flag details and request id
1617
+ Calls /flags and returns the flag details and request id
1571
1618
  """
1572
1619
  resp_data = self.get_flags_decision(
1573
- distinct_id, groups, person_properties, group_properties, disable_geoip
1620
+ distinct_id,
1621
+ groups,
1622
+ person_properties,
1623
+ group_properties,
1624
+ disable_geoip,
1625
+ flag_keys_to_evaluate=[key],
1574
1626
  )
1575
1627
  request_id = resp_data.get("requestId")
1576
1628
  flags = resp_data.get("flags")
@@ -1686,6 +1738,7 @@ class Client(object):
1686
1738
  group_properties=None,
1687
1739
  only_evaluate_locally=False,
1688
1740
  disable_geoip=None,
1741
+ flag_keys_to_evaluate: Optional[list[str]] = None,
1689
1742
  ) -> Optional[dict[str, Union[bool, str]]]:
1690
1743
  """
1691
1744
  Get all feature flags for a user.
@@ -1697,6 +1750,8 @@ class Client(object):
1697
1750
  group_properties: A dictionary of group properties.
1698
1751
  only_evaluate_locally: Whether to only evaluate locally.
1699
1752
  disable_geoip: Whether to disable GeoIP for this request.
1753
+ flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided,
1754
+ only these flags will be evaluated, improving performance.
1700
1755
 
1701
1756
  Examples:
1702
1757
  ```python
@@ -1713,6 +1768,7 @@ class Client(object):
1713
1768
  group_properties=group_properties,
1714
1769
  only_evaluate_locally=only_evaluate_locally,
1715
1770
  disable_geoip=disable_geoip,
1771
+ flag_keys_to_evaluate=flag_keys_to_evaluate,
1716
1772
  )
1717
1773
 
1718
1774
  return response["featureFlags"]
@@ -1726,6 +1782,7 @@ class Client(object):
1726
1782
  group_properties=None,
1727
1783
  only_evaluate_locally=False,
1728
1784
  disable_geoip=None,
1785
+ flag_keys_to_evaluate: Optional[list[str]] = None,
1729
1786
  ) -> FlagsAndPayloads:
1730
1787
  """
1731
1788
  Get all feature flags and their payloads for a user.
@@ -1737,6 +1794,8 @@ class Client(object):
1737
1794
  group_properties: A dictionary of group properties.
1738
1795
  only_evaluate_locally: Whether to only evaluate locally.
1739
1796
  disable_geoip: Whether to disable GeoIP for this request.
1797
+ flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided,
1798
+ only these flags will be evaluated, improving performance.
1740
1799
 
1741
1800
  Examples:
1742
1801
  ```python
@@ -1760,6 +1819,7 @@ class Client(object):
1760
1819
  groups=groups,
1761
1820
  person_properties=person_properties,
1762
1821
  group_properties=group_properties,
1822
+ flag_keys_to_evaluate=flag_keys_to_evaluate,
1763
1823
  )
1764
1824
 
1765
1825
  if fallback_to_decide and not only_evaluate_locally:
@@ -1770,6 +1830,7 @@ class Client(object):
1770
1830
  person_properties=person_properties,
1771
1831
  group_properties=group_properties,
1772
1832
  disable_geoip=disable_geoip,
1833
+ flag_keys_to_evaluate=flag_keys_to_evaluate,
1773
1834
  )
1774
1835
  return to_flags_and_payloads(decide_response)
1775
1836
  except Exception as e:
@@ -1787,6 +1848,7 @@ class Client(object):
1787
1848
  person_properties=None,
1788
1849
  group_properties=None,
1789
1850
  warn_on_unknown_groups=False,
1851
+ flag_keys_to_evaluate: Optional[list[str]] = None,
1790
1852
  ) -> tuple[FlagsAndPayloads, bool]:
1791
1853
  person_properties = person_properties or {}
1792
1854
  group_properties = group_properties or {}
@@ -1799,7 +1861,15 @@ class Client(object):
1799
1861
  fallback_to_decide = False
1800
1862
  # If loading in previous line failed
1801
1863
  if self.feature_flags:
1802
- for flag in self.feature_flags:
1864
+ # Filter flags based on flag_keys_to_evaluate if provided
1865
+ flags_to_process = self.feature_flags
1866
+ if flag_keys_to_evaluate:
1867
+ flag_keys_set = set(flag_keys_to_evaluate)
1868
+ flags_to_process = [
1869
+ flag for flag in self.feature_flags if flag["key"] in flag_keys_set
1870
+ ]
1871
+
1872
+ for flag in flags_to_process:
1803
1873
  try:
1804
1874
  flags[flag["key"]] = self._compute_flag_locally(
1805
1875
  flag,
@@ -1815,7 +1885,7 @@ class Client(object):
1815
1885
  if matched_payload is not None:
1816
1886
  payloads[flag["key"]] = matched_payload
1817
1887
  except InconclusiveMatchError:
1818
- # No need to log this, since it's just telling us to fall back to `/decide`
1888
+ # No need to log this, since it's just telling us to fall back to `/flags`
1819
1889
  fallback_to_decide = True
1820
1890
  except Exception as e:
1821
1891
  self.log.exception(
@@ -1939,7 +2009,7 @@ class Client(object):
1939
2009
  for group_name in groups:
1940
2010
  all_group_properties[group_name] = {
1941
2011
  "$group_key": groups[group_name],
1942
- **(group_properties.get(group_name) or {}),
2012
+ **((group_properties or {}).get(group_name) or {}),
1943
2013
  }
1944
2014
 
1945
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