posthoganalytics 6.6.0__py3-none-any.whl → 6.7.0__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.
@@ -8,7 +8,7 @@ except ImportError:
8
8
 
9
9
  import time
10
10
  import uuid
11
- from typing import Any, Dict, Optional, cast
11
+ from typing import Any, Dict, Optional
12
12
 
13
13
  from posthoganalytics.ai.utils import (
14
14
  call_llm_and_track_usage,
@@ -1,6 +1,6 @@
1
1
  import time
2
2
  import uuid
3
- from typing import Any, Dict, List, Optional, cast
3
+ from typing import Any, Dict, List, Optional
4
4
 
5
5
  try:
6
6
  import openai
@@ -329,7 +329,7 @@ class Client(object):
329
329
  only these flags will be evaluated, improving performance.
330
330
 
331
331
  Category:
332
- Feature Flags
332
+ Feature flags
333
333
  """
334
334
  resp_data = self.get_flags_decision(
335
335
  distinct_id,
@@ -368,7 +368,7 @@ class Client(object):
368
368
  ```
369
369
 
370
370
  Category:
371
- Feature Flags
371
+ Feature flags
372
372
  """
373
373
  resp_data = self.get_flags_decision(
374
374
  distinct_id,
@@ -407,7 +407,7 @@ class Client(object):
407
407
  ```
408
408
 
409
409
  Category:
410
- Feature Flags
410
+ Feature flags
411
411
  """
412
412
  resp = self.get_flags_decision(
413
413
  distinct_id,
@@ -446,7 +446,7 @@ class Client(object):
446
446
  ```
447
447
 
448
448
  Category:
449
- Feature Flags
449
+ Feature flags
450
450
  """
451
451
  groups = groups or {}
452
452
  person_properties = person_properties or {}
@@ -1169,7 +1169,7 @@ class Client(object):
1169
1169
  ```
1170
1170
 
1171
1171
  Category:
1172
- Feature Flags
1172
+ Feature flags
1173
1173
  """
1174
1174
  if not self.personal_api_key:
1175
1175
  self.log.warning(
@@ -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(
@@ -1279,7 +1291,7 @@ class Client(object):
1279
1291
  ```
1280
1292
 
1281
1293
  Category:
1282
- Feature Flags
1294
+ Feature flags
1283
1295
  """
1284
1296
  response = self.get_feature_flag(
1285
1297
  key,
@@ -1487,7 +1499,7 @@ class Client(object):
1487
1499
  ```
1488
1500
 
1489
1501
  Category:
1490
- Feature Flags
1502
+ Feature flags
1491
1503
  """
1492
1504
  feature_flag_result = self.get_feature_flag_result(
1493
1505
  key,
@@ -1577,7 +1589,7 @@ class Client(object):
1577
1589
  ```
1578
1590
 
1579
1591
  Category:
1580
- Feature Flags
1592
+ Feature flags
1581
1593
  """
1582
1594
  feature_flag_result = self._get_feature_flag_result(
1583
1595
  key,
@@ -1747,7 +1759,7 @@ class Client(object):
1747
1759
  ```
1748
1760
 
1749
1761
  Category:
1750
- Feature Flags
1762
+ Feature flags
1751
1763
  """
1752
1764
  response = self.get_all_flags_and_payloads(
1753
1765
  distinct_id,
@@ -1791,7 +1803,7 @@ class Client(object):
1791
1803
  ```
1792
1804
 
1793
1805
  Category:
1794
- Feature Flags
1806
+ Feature flags
1795
1807
  """
1796
1808
  if self.disabled:
1797
1809
  return {"featureFlags": None, "featureFlagPayloads": None}
@@ -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,161 @@ 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
+ # All dependencies in the chain have been evaluated successfully
143
+ # Now check if the final flag value matches the expected value in the property
144
+ flag_key = property.get("key")
145
+ expected_value = property.get("value")
146
+ operator = property.get("operator", "exact")
147
+
148
+ if flag_key and expected_value is not None:
149
+ # Get the actual value of the flag we're checking
150
+ actual_value = evaluation_cache.get(flag_key)
151
+
152
+ if actual_value is None:
153
+ # Flag wasn't evaluated - this shouldn't happen if dependency chain is correct
154
+ raise InconclusiveMatchError(
155
+ f"Flag '{flag_key}' was not evaluated despite being in dependency chain"
156
+ )
157
+
158
+ # For flag dependencies, we need to compare the actual flag result with expected value
159
+ # using the flag_evaluates_to operator logic
160
+ if operator == "flag_evaluates_to":
161
+ return matches_dependency_value(expected_value, actual_value)
162
+ else:
163
+ # This should never happen, but just to be defensive.
164
+ raise InconclusiveMatchError(
165
+ f"Flag dependency property for '{property.get('key', 'unknown')}' has invalid operator '{operator}'"
166
+ )
167
+
168
+ # If no value check needed, return True (all dependencies passed)
169
+ return True
170
+
171
+
172
+ def matches_dependency_value(expected_value, actual_value):
173
+ """
174
+ Check if the actual flag value matches the expected dependency value.
175
+
176
+ This follows the same logic as the C# MatchesDependencyValue function:
177
+ - String variant case: check for exact match or boolean true
178
+ - Boolean case: must match expected boolean value
179
+
180
+ Args:
181
+ expected_value: The expected value from the property
182
+ actual_value: The actual value returned by the flag evaluation
183
+
184
+ Returns:
185
+ bool: True if the values match according to flag dependency rules
186
+ """
187
+ # String variant case - check for exact match or boolean true
188
+ if isinstance(actual_value, str) and len(actual_value) > 0:
189
+ if isinstance(expected_value, bool):
190
+ # Any variant matches boolean true
191
+ return expected_value
192
+ elif isinstance(expected_value, str):
193
+ # variants are case-sensitive, hence our comparison is too
194
+ return actual_value == expected_value
195
+ else:
196
+ return False
197
+
198
+ # Boolean case - must match expected boolean value
199
+ elif isinstance(actual_value, bool) and isinstance(expected_value, bool):
200
+ return actual_value == expected_value
201
+
202
+ # Default case
203
+ return False
204
+
205
+
58
206
  def match_feature_flag_properties(
59
- flag, distinct_id, properties, cohort_properties=None
207
+ flag,
208
+ distinct_id,
209
+ properties,
210
+ cohort_properties=None,
211
+ flags_by_key=None,
212
+ evaluation_cache=None,
60
213
  ) -> FlagValue:
61
214
  flag_conditions = (flag.get("filters") or {}).get("groups") or []
62
215
  is_inconclusive = False
@@ -79,7 +232,13 @@ def match_feature_flag_properties(
79
232
  # if any one condition resolves to True, we can shortcircuit and return
80
233
  # the matching variant
81
234
  if is_condition_match(
82
- flag, distinct_id, condition, properties, cohort_properties
235
+ flag,
236
+ distinct_id,
237
+ condition,
238
+ properties,
239
+ cohort_properties,
240
+ flags_by_key,
241
+ evaluation_cache,
83
242
  ):
84
243
  variant_override = condition.get("variant")
85
244
  if variant_override and variant_override in valid_variant_keys:
@@ -101,22 +260,36 @@ def match_feature_flag_properties(
101
260
 
102
261
 
103
262
  def is_condition_match(
104
- feature_flag, distinct_id, condition, properties, cohort_properties
263
+ feature_flag,
264
+ distinct_id,
265
+ condition,
266
+ properties,
267
+ cohort_properties,
268
+ flags_by_key=None,
269
+ evaluation_cache=None,
105
270
  ) -> bool:
106
271
  rollout_percentage = condition.get("rollout_percentage")
107
272
  if len(condition.get("properties") or []) > 0:
108
273
  for prop in condition.get("properties"):
109
274
  property_type = prop.get("type")
110
275
  if property_type == "cohort":
111
- matches = match_cohort(prop, properties, cohort_properties)
276
+ matches = match_cohort(
277
+ prop,
278
+ properties,
279
+ cohort_properties,
280
+ flags_by_key,
281
+ evaluation_cache,
282
+ distinct_id,
283
+ )
112
284
  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"),
285
+ matches = evaluate_flag_dependency(
286
+ prop,
287
+ flags_by_key,
288
+ evaluation_cache,
289
+ distinct_id,
290
+ properties,
291
+ cohort_properties,
118
292
  )
119
- continue
120
293
  else:
121
294
  matches = match_property(prop, properties)
122
295
  if not matches:
@@ -264,7 +437,14 @@ def match_property(property, property_values) -> bool:
264
437
  raise InconclusiveMatchError(f"Unknown operator {operator}")
265
438
 
266
439
 
267
- def match_cohort(property, property_values, cohort_properties) -> bool:
440
+ def match_cohort(
441
+ property,
442
+ property_values,
443
+ cohort_properties,
444
+ flags_by_key=None,
445
+ evaluation_cache=None,
446
+ distinct_id=None,
447
+ ) -> bool:
268
448
  # Cohort properties are in the form of property groups like this:
269
449
  # {
270
450
  # "cohort_id": {
@@ -281,10 +461,24 @@ def match_cohort(property, property_values, cohort_properties) -> bool:
281
461
  )
282
462
 
283
463
  property_group = cohort_properties[cohort_id]
284
- return match_property_group(property_group, property_values, cohort_properties)
464
+ return match_property_group(
465
+ property_group,
466
+ property_values,
467
+ cohort_properties,
468
+ flags_by_key,
469
+ evaluation_cache,
470
+ distinct_id,
471
+ )
285
472
 
286
473
 
287
- def match_property_group(property_group, property_values, cohort_properties) -> bool:
474
+ def match_property_group(
475
+ property_group,
476
+ property_values,
477
+ cohort_properties,
478
+ flags_by_key=None,
479
+ evaluation_cache=None,
480
+ distinct_id=None,
481
+ ) -> bool:
288
482
  if not property_group:
289
483
  return True
290
484
 
@@ -301,7 +495,14 @@ def match_property_group(property_group, property_values, cohort_properties) ->
301
495
  # a nested property group
302
496
  for prop in properties:
303
497
  try:
304
- matches = match_property_group(prop, property_values, cohort_properties)
498
+ matches = match_property_group(
499
+ prop,
500
+ property_values,
501
+ cohort_properties,
502
+ flags_by_key,
503
+ evaluation_cache,
504
+ distinct_id,
505
+ )
305
506
  if property_group_type == "AND":
306
507
  if not matches:
307
508
  return False
@@ -324,14 +525,23 @@ def match_property_group(property_group, property_values, cohort_properties) ->
324
525
  for prop in properties:
325
526
  try:
326
527
  if prop.get("type") == "cohort":
327
- matches = match_cohort(prop, property_values, cohort_properties)
528
+ matches = match_cohort(
529
+ prop,
530
+ property_values,
531
+ cohort_properties,
532
+ flags_by_key,
533
+ evaluation_cache,
534
+ distinct_id,
535
+ )
328
536
  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"),
537
+ matches = evaluate_flag_dependency(
538
+ prop,
539
+ flags_by_key,
540
+ evaluation_cache,
541
+ distinct_id,
542
+ property_values,
543
+ cohort_properties,
333
544
  )
334
- continue
335
545
  else:
336
546
  matches = match_property(prop, property_values)
337
547