posthoganalytics 6.6.0__py3-none-any.whl → 6.6.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- posthoganalytics/client.py +15 -3
- posthoganalytics/feature_flags.py +169 -20
- posthoganalytics/test/test_feature_flags.py +428 -19
- posthoganalytics/version.py +1 -1
- {posthoganalytics-6.6.0.dist-info → posthoganalytics-6.6.1.dist-info}/METADATA +1 -1
- {posthoganalytics-6.6.0.dist-info → posthoganalytics-6.6.1.dist-info}/RECORD +9 -9
- {posthoganalytics-6.6.0.dist-info → posthoganalytics-6.6.1.dist-info}/WHEEL +0 -0
- {posthoganalytics-6.6.0.dist-info → posthoganalytics-6.6.1.dist-info}/licenses/LICENSE +0 -0
- {posthoganalytics-6.6.0.dist-info → posthoganalytics-6.6.1.dist-info}/top_level.txt +0 -0
posthoganalytics/client.py
CHANGED
|
@@ -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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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(
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
1396
|
-
# The flag
|
|
1397
|
-
# Since
|
|
1398
|
-
#
|
|
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.
|
|
1405
|
-
self.assertEqual(patch_flags.call_count,
|
|
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
|
-
#
|
|
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.
|
|
1423
|
-
self.assertEqual(patch_flags.call_count,
|
|
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
|
-
|
|
1427
|
-
|
|
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")
|
posthoganalytics/version.py
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
posthoganalytics/__init__.py,sha256=66HkeJ1fkzbKC2ggl3F164oajFeiGm8v84kJR0Yf5BI,25987
|
|
2
2
|
posthoganalytics/args.py,sha256=iZ2JWeANiAREJKhS-Qls9tIngjJOSfAVR8C4xFT5sHw,3307
|
|
3
|
-
posthoganalytics/client.py,sha256=
|
|
3
|
+
posthoganalytics/client.py,sha256=UXIUoe0gRp1SYFKiy9vLKIajBwY2KyB9Etv5-0ZlEr8,71654
|
|
4
4
|
posthoganalytics/consumer.py,sha256=CiNbJBdyW9jER3ZYCKbX-JFmEDXlE1lbDy1MSl43-a0,4617
|
|
5
5
|
posthoganalytics/contexts.py,sha256=LFSFIYpUFWKTBnGMjV9n1aYHWbAzz5zLJGr2qG34PoE,9405
|
|
6
6
|
posthoganalytics/exception_capture.py,sha256=1VHBfffrXXrkK0PT8iVgKPpj_R1pGAzG5f3Qw0WF79w,1783
|
|
7
7
|
posthoganalytics/exception_utils.py,sha256=P_75873Y2jayqlLiIkbxCNE7Bc8cM6J9kfrdZ5ZSnA0,26696
|
|
8
|
-
posthoganalytics/feature_flags.py,sha256=
|
|
8
|
+
posthoganalytics/feature_flags.py,sha256=lWeBhjHYfk338ZN6Ib-yT7-PscLy7kqZIGFyhLdTIYY,19325
|
|
9
9
|
posthoganalytics/poller.py,sha256=jBz5rfH_kn_bBz7wCB46Fpvso4ttx4uzqIZWvXBCFmQ,595
|
|
10
10
|
posthoganalytics/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
11
|
posthoganalytics/request.py,sha256=Bsl2c5WwONKPQzwWMmKPX5VgOlwSiIcSNfhXgoz62Y8,6186
|
|
12
12
|
posthoganalytics/types.py,sha256=Dl3aFGX9XUR0wMmK12r2s5Hjan9jL4HpQ9GHpVcEq5U,10207
|
|
13
13
|
posthoganalytics/utils.py,sha256=-0w-OLcCaoldkbBebPzQyBzLJSo9G9yBOg8NDVz7La8,16088
|
|
14
|
-
posthoganalytics/version.py,sha256=
|
|
14
|
+
posthoganalytics/version.py,sha256=LB6qjMDtZV5cJSTWNfGLOSZbTVne-YozmEDDYmRfjJY,87
|
|
15
15
|
posthoganalytics/ai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
16
|
posthoganalytics/ai/utils.py,sha256=92RlL395wjL5V9FstS8BeebwMtaz6DP6zS9miCNla9M,21106
|
|
17
17
|
posthoganalytics/ai/anthropic/__init__.py,sha256=fFhDOiRzTXzGQlgnrRDL-4yKC8EYIl8NW4a2QNR6xRU,368
|
|
@@ -36,14 +36,14 @@ posthoganalytics/test/test_contexts.py,sha256=c--hNUIEf6SHQ7H9vdPhU1oLCN0SnD4wDb
|
|
|
36
36
|
posthoganalytics/test/test_exception_capture.py,sha256=al37Kg6wjzL_IBCFUUXRvkP6nVrqS6IZRCOKSo29Nh8,1063
|
|
37
37
|
posthoganalytics/test/test_feature_flag.py,sha256=9RQwB5eCvVAGrrO7UnR3Z1OidP_YoL4iBl3A83fuAig,6824
|
|
38
38
|
posthoganalytics/test/test_feature_flag_result.py,sha256=z2OgD97r85LKMqCnoCqAs74WjUMucayAtC3qWaITGCA,15898
|
|
39
|
-
posthoganalytics/test/test_feature_flags.py,sha256=
|
|
39
|
+
posthoganalytics/test/test_feature_flags.py,sha256=gFILn07WykFtJE_QpkJ_c9u_JeoGnegixxs6BffCR0s,191526
|
|
40
40
|
posthoganalytics/test/test_module.py,sha256=viqaAWA_uHt8r20fHIeME6IQkeXmQ8ZyrJTtPGQAb1E,1070
|
|
41
41
|
posthoganalytics/test/test_request.py,sha256=Zc0VbkjpVmj8mKokQm9rzdgTr0b1U44vvMYSkB_IQLs,4467
|
|
42
42
|
posthoganalytics/test/test_size_limited_dict.py,sha256=-5IQjIEr_-Dql24M0HusdR_XroOMrtgiT0v6ZQCRvzo,774
|
|
43
43
|
posthoganalytics/test/test_types.py,sha256=bRPHdwVpP7hu7emsplU8UVyzSQptv6PaG5lAoOD_BtM,7595
|
|
44
44
|
posthoganalytics/test/test_utils.py,sha256=sqUTbfweVcxxFRd3WDMFXqPMyU6DvzOBeAOc68Py9aw,9620
|
|
45
|
-
posthoganalytics-6.6.
|
|
46
|
-
posthoganalytics-6.6.
|
|
47
|
-
posthoganalytics-6.6.
|
|
48
|
-
posthoganalytics-6.6.
|
|
49
|
-
posthoganalytics-6.6.
|
|
45
|
+
posthoganalytics-6.6.1.dist-info/licenses/LICENSE,sha256=wGf9JBotDkSygFj43m49oiKlFnpMnn97keiZKF-40vE,2450
|
|
46
|
+
posthoganalytics-6.6.1.dist-info/METADATA,sha256=80BPZRBZWY8hPhIi62ql0HZ7l1gajIBUNYxvdnwxYic,6024
|
|
47
|
+
posthoganalytics-6.6.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
48
|
+
posthoganalytics-6.6.1.dist-info/top_level.txt,sha256=8QsNIqIkBh1p2TXvKp0Em9ZLZKwe3uIqCETyW4s1GOE,17
|
|
49
|
+
posthoganalytics-6.6.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|