posthoganalytics 6.5.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 +87 -17
- posthoganalytics/feature_flags.py +169 -20
- posthoganalytics/test/test_client.py +56 -0
- posthoganalytics/test/test_feature_flags.py +546 -26
- posthoganalytics/types.py +3 -0
- posthoganalytics/version.py +1 -1
- {posthoganalytics-6.5.0.dist-info → posthoganalytics-6.6.1.dist-info}/METADATA +1 -1
- {posthoganalytics-6.5.0.dist-info → posthoganalytics-6.6.1.dist-info}/RECORD +11 -11
- {posthoganalytics-6.5.0.dist-info → posthoganalytics-6.6.1.dist-info}/WHEEL +0 -0
- {posthoganalytics-6.5.0.dist-info → posthoganalytics-6.6.1.dist-info}/licenses/LICENSE +0 -0
- {posthoganalytics-6.5.0.dist-info → posthoganalytics-6.6.1.dist-info}/top_level.txt +0 -0
|
@@ -215,7 +215,7 @@ class TestLocalEvaluation(unittest.TestCase):
|
|
|
215
215
|
)
|
|
216
216
|
self.assertEqual(patch_flags.call_count, 0)
|
|
217
217
|
|
|
218
|
-
# Now group type mappings are gone, so fall back to /
|
|
218
|
+
# Now group type mappings are gone, so fall back to /flags/
|
|
219
219
|
patch_flags.return_value = {
|
|
220
220
|
"featureFlags": {"group-flag": "decide-fallback-value"}
|
|
221
221
|
}
|
|
@@ -311,7 +311,7 @@ class TestLocalEvaluation(unittest.TestCase):
|
|
|
311
311
|
)
|
|
312
312
|
self.assertEqual(patch_flags.call_count, 0)
|
|
313
313
|
|
|
314
|
-
# will fall back on `/
|
|
314
|
+
# will fall back on `/flags`, as all properties present for second group, but that group resolves to false
|
|
315
315
|
self.assertEqual(
|
|
316
316
|
client.get_feature_flag(
|
|
317
317
|
"complex-flag",
|
|
@@ -651,7 +651,7 @@ class TestLocalEvaluation(unittest.TestCase):
|
|
|
651
651
|
},
|
|
652
652
|
},
|
|
653
653
|
]
|
|
654
|
-
# beta-feature value overridden by /
|
|
654
|
+
# beta-feature value overridden by /flags
|
|
655
655
|
self.assertEqual(
|
|
656
656
|
client.get_all_flags("distinct_id"),
|
|
657
657
|
{
|
|
@@ -725,7 +725,7 @@ class TestLocalEvaluation(unittest.TestCase):
|
|
|
725
725
|
},
|
|
726
726
|
},
|
|
727
727
|
]
|
|
728
|
-
# beta-feature value overridden by /
|
|
728
|
+
# beta-feature value overridden by /flags
|
|
729
729
|
self.assertEqual(
|
|
730
730
|
client.get_all_flags_and_payloads("distinct_id")["featureFlagPayloads"],
|
|
731
731
|
{
|
|
@@ -746,7 +746,7 @@ class TestLocalEvaluation(unittest.TestCase):
|
|
|
746
746
|
}
|
|
747
747
|
client = self.client
|
|
748
748
|
client.feature_flags = []
|
|
749
|
-
# beta-feature value overridden by /
|
|
749
|
+
# beta-feature value overridden by /flags
|
|
750
750
|
self.assertEqual(
|
|
751
751
|
client.get_all_flags("distinct_id"),
|
|
752
752
|
{"beta-feature": "variant-1", "beta-feature2": "variant-2"},
|
|
@@ -765,7 +765,7 @@ class TestLocalEvaluation(unittest.TestCase):
|
|
|
765
765
|
}
|
|
766
766
|
client = self.client
|
|
767
767
|
client.feature_flags = []
|
|
768
|
-
# beta-feature value overridden by /
|
|
768
|
+
# beta-feature value overridden by /flags
|
|
769
769
|
self.assertEqual(
|
|
770
770
|
client.get_all_flags_and_payloads("distinct_id")["featureFlagPayloads"],
|
|
771
771
|
{"beta-feature": 100, "beta-feature2": 300},
|
|
@@ -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")
|
|
@@ -5387,4 +5796,115 @@ class TestConsistency(unittest.TestCase):
|
|
|
5387
5796
|
test_cases = ["beta-feature", "BETA-FEATURE", "bEtA-FeAtUrE"]
|
|
5388
5797
|
for case in test_cases:
|
|
5389
5798
|
self.assertFalse(client.feature_enabled(case, "user1"))
|
|
5390
|
-
|
|
5799
|
+
|
|
5800
|
+
@mock.patch("posthog.client.flags")
|
|
5801
|
+
def test_get_all_flags_with_flag_keys_to_evaluate(self, mock_flags):
|
|
5802
|
+
"""Test that get_all_flags with flag_keys_to_evaluate only evaluates specified flags"""
|
|
5803
|
+
mock_flags.return_value = {
|
|
5804
|
+
"featureFlags": {
|
|
5805
|
+
"flag1": "value1",
|
|
5806
|
+
"flag2": True,
|
|
5807
|
+
}
|
|
5808
|
+
}
|
|
5809
|
+
|
|
5810
|
+
client = Client(
|
|
5811
|
+
project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
|
|
5812
|
+
)
|
|
5813
|
+
|
|
5814
|
+
# Call get_all_flags with flag_keys_to_evaluate
|
|
5815
|
+
result = client.get_all_flags(
|
|
5816
|
+
"user123",
|
|
5817
|
+
flag_keys_to_evaluate=["flag1", "flag2"],
|
|
5818
|
+
person_properties={"region": "USA"},
|
|
5819
|
+
)
|
|
5820
|
+
|
|
5821
|
+
# Verify flags() was called with flag_keys_to_evaluate
|
|
5822
|
+
mock_flags.assert_called_once()
|
|
5823
|
+
call_args = mock_flags.call_args[1]
|
|
5824
|
+
self.assertEqual(call_args["flag_keys_to_evaluate"], ["flag1", "flag2"])
|
|
5825
|
+
self.assertEqual(
|
|
5826
|
+
call_args["person_properties"], {"distinct_id": "user123", "region": "USA"}
|
|
5827
|
+
)
|
|
5828
|
+
|
|
5829
|
+
# Check the result
|
|
5830
|
+
self.assertEqual(result, {"flag1": "value1", "flag2": True})
|
|
5831
|
+
|
|
5832
|
+
@mock.patch("posthog.client.flags")
|
|
5833
|
+
def test_get_all_flags_and_payloads_with_flag_keys_to_evaluate(self, mock_flags):
|
|
5834
|
+
"""Test that get_all_flags_and_payloads with flag_keys_to_evaluate only evaluates specified flags"""
|
|
5835
|
+
mock_flags.return_value = {
|
|
5836
|
+
"featureFlags": {
|
|
5837
|
+
"flag1": "variant1",
|
|
5838
|
+
"flag3": True,
|
|
5839
|
+
},
|
|
5840
|
+
"featureFlagPayloads": {
|
|
5841
|
+
"flag1": {"data": "payload1"},
|
|
5842
|
+
"flag3": {"data": "payload3"},
|
|
5843
|
+
},
|
|
5844
|
+
}
|
|
5845
|
+
|
|
5846
|
+
client = Client(
|
|
5847
|
+
project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
|
|
5848
|
+
)
|
|
5849
|
+
|
|
5850
|
+
# Call get_all_flags_and_payloads with flag_keys_to_evaluate
|
|
5851
|
+
result = client.get_all_flags_and_payloads(
|
|
5852
|
+
"user123",
|
|
5853
|
+
flag_keys_to_evaluate=["flag1", "flag3"],
|
|
5854
|
+
person_properties={"subscription": "pro"},
|
|
5855
|
+
)
|
|
5856
|
+
|
|
5857
|
+
# Verify flags() was called with flag_keys_to_evaluate
|
|
5858
|
+
mock_flags.assert_called_once()
|
|
5859
|
+
call_args = mock_flags.call_args[1]
|
|
5860
|
+
self.assertEqual(call_args["flag_keys_to_evaluate"], ["flag1", "flag3"])
|
|
5861
|
+
self.assertEqual(
|
|
5862
|
+
call_args["person_properties"],
|
|
5863
|
+
{"distinct_id": "user123", "subscription": "pro"},
|
|
5864
|
+
)
|
|
5865
|
+
|
|
5866
|
+
# Check the result
|
|
5867
|
+
self.assertEqual(result["featureFlags"], {"flag1": "variant1", "flag3": True})
|
|
5868
|
+
self.assertEqual(
|
|
5869
|
+
result["featureFlagPayloads"],
|
|
5870
|
+
{"flag1": {"data": "payload1"}, "flag3": {"data": "payload3"}},
|
|
5871
|
+
)
|
|
5872
|
+
|
|
5873
|
+
def test_get_all_flags_locally_with_flag_keys_to_evaluate(self):
|
|
5874
|
+
"""Test that local evaluation with flag_keys_to_evaluate only evaluates specified flags"""
|
|
5875
|
+
client = Client(
|
|
5876
|
+
project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
|
|
5877
|
+
)
|
|
5878
|
+
|
|
5879
|
+
# Set up multiple flags
|
|
5880
|
+
client.feature_flags = [
|
|
5881
|
+
{
|
|
5882
|
+
"id": 1,
|
|
5883
|
+
"key": "flag1",
|
|
5884
|
+
"active": True,
|
|
5885
|
+
"filters": {"groups": [{"properties": [], "rollout_percentage": 100}]},
|
|
5886
|
+
},
|
|
5887
|
+
{
|
|
5888
|
+
"id": 2,
|
|
5889
|
+
"key": "flag2",
|
|
5890
|
+
"active": True,
|
|
5891
|
+
"filters": {"groups": [{"properties": [], "rollout_percentage": 100}]},
|
|
5892
|
+
},
|
|
5893
|
+
{
|
|
5894
|
+
"id": 3,
|
|
5895
|
+
"key": "flag3",
|
|
5896
|
+
"active": True,
|
|
5897
|
+
"filters": {"groups": [{"properties": [], "rollout_percentage": 100}]},
|
|
5898
|
+
},
|
|
5899
|
+
]
|
|
5900
|
+
|
|
5901
|
+
# Call get_all_flags with flag_keys_to_evaluate
|
|
5902
|
+
result = client.get_all_flags(
|
|
5903
|
+
"user123",
|
|
5904
|
+
flag_keys_to_evaluate=["flag1", "flag3"],
|
|
5905
|
+
only_evaluate_locally=True,
|
|
5906
|
+
)
|
|
5907
|
+
|
|
5908
|
+
# Should only return flag1 and flag3
|
|
5909
|
+
self.assertEqual(result, {"flag1": True, "flag3": True})
|
|
5910
|
+
self.assertNotIn("flag2", result)
|
posthoganalytics/types.py
CHANGED
|
@@ -9,6 +9,7 @@ FlagValue = Union[bool, str]
|
|
|
9
9
|
BeforeSendCallback = Callable[[dict[str, Any]], Optional[dict[str, Any]]]
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
# Type alias for the send_feature_flags parameter
|
|
12
13
|
class SendFeatureFlagsOptions(TypedDict, total=False):
|
|
13
14
|
"""Options for sending feature flags with capture events.
|
|
14
15
|
|
|
@@ -22,9 +23,11 @@ class SendFeatureFlagsOptions(TypedDict, total=False):
|
|
|
22
23
|
Format: { group_type_name: { group_properties } }
|
|
23
24
|
"""
|
|
24
25
|
|
|
26
|
+
should_send: bool
|
|
25
27
|
only_evaluate_locally: Optional[bool]
|
|
26
28
|
person_properties: Optional[dict[str, Any]]
|
|
27
29
|
group_properties: Optional[dict[str, dict[str, Any]]]
|
|
30
|
+
flag_keys_filter: Optional[list[str]]
|
|
28
31
|
|
|
29
32
|
|
|
30
33
|
@dataclass(frozen=True)
|
posthoganalytics/version.py
CHANGED