From 068c4d90d62f716490efac3679b8efed31ad3616 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Tue, 22 Apr 2025 14:11:05 -0700 Subject: [PATCH 01/20] Add new `FeatureFlagResult` class and tests --- posthog/test/test_feature_flag_result.py | 113 +++++++++++++++++++++++ posthog/types.py | 39 ++++++++ 2 files changed, 152 insertions(+) create mode 100644 posthog/test/test_feature_flag_result.py diff --git a/posthog/test/test_feature_flag_result.py b/posthog/test/test_feature_flag_result.py new file mode 100644 index 00000000..68fe5c87 --- /dev/null +++ b/posthog/test/test_feature_flag_result.py @@ -0,0 +1,113 @@ +import unittest + + +from posthog.types import FeatureFlagResult, FeatureFlag, FlagMetadata, FlagReason + +class TestFeatureFlagResult(unittest.TestCase): + def test_from_bool_value_and_payload(self): + result = FeatureFlagResult.from_value_and_payload("test-flag", True, '{"some": "value"}') + + self.assertEqual(result.key, "test-flag") + self.assertEqual(result.enabled, True) + self.assertEqual(result.variant, None) + self.assertEqual(result.payload, '{"some": "value"}') + + def test_from_bool_value_and_payload(self): + result = FeatureFlagResult.from_value_and_payload("test-flag", False, '{"some": "value"}') + + self.assertEqual(result.key, "test-flag") + self.assertEqual(result.enabled, False) + self.assertEqual(result.variant, None) + self.assertEqual(result.payload, '{"some": "value"}') + + def test_from_variant_value_and_payload(self): + result = FeatureFlagResult.from_value_and_payload("test-flag", "control", '{"some": "value"}') + + self.assertEqual(result.key, "test-flag") + self.assertEqual(result.enabled, True) + self.assertEqual(result.variant, "control") + self.assertEqual(result.payload, '{"some": "value"}') + + def test_from_none_value_and_payload(self): + result = FeatureFlagResult.from_value_and_payload("test-flag", None, '{"some": "value"}') + self.assertIsNone(result) + + def test_from_boolean_flag_details(self): + flag_details = FeatureFlag( + key="test-flag", + enabled=True, + variant=None, + metadata=FlagMetadata( + id=1, + version=1, + description="test-flag", + payload='{"some": "value"}' + ), + reason=FlagReason( + code="test-reason", + description="test-reason", + condition_index=0 + ) + ) + + result = FeatureFlagResult.from_flag_details(flag_details) + + self.assertEqual(result.key, "test-flag") + self.assertEqual(result.enabled, True) + self.assertEqual(result.variant, None) + self.assertEqual(result.payload, '{"some": "value"}') + + def test_from_variant_flag_details(self): + flag_details = FeatureFlag( + key="test-flag", + enabled=True, + variant="control", + metadata=FlagMetadata( + id=1, + version=1, + description="test-flag", + payload='{"some": "value"}' + ), + reason=FlagReason( + code="test-reason", + description="test-reason", + condition_index=0 + ) + ) + + result = FeatureFlagResult.from_flag_details(flag_details) + + self.assertEqual(result.key, "test-flag") + self.assertEqual(result.enabled, True) + self.assertEqual(result.variant, "control") + self.assertEqual(result.payload, '{"some": "value"}') + + def test_from_none_flag_details(self): + result = FeatureFlagResult.from_flag_details(None) + + self.assertIsNone(result) + + def test_from_flag_details_with_none_payload(self): + flag_details = FeatureFlag( + key="test-flag", + enabled=True, + variant=None, + metadata=FlagMetadata( + id=1, + version=1, + description="test-flag", + payload=None + ), + reason=FlagReason( + code="test-reason", + description="test-reason", + condition_index=0 + ) + ) + + result = FeatureFlagResult.from_flag_details(flag_details) + + self.assertEqual(result.key, "test-flag") + self.assertEqual(result.enabled, True) + self.assertEqual(result.variant, None) + self.assertIsNone(result.payload) \ No newline at end of file diff --git a/posthog/types.py b/posthog/types.py index 42a9f34e..4a9cd5e0 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -101,6 +101,45 @@ class FlagsAndPayloads(TypedDict, total=True): featureFlags: Optional[dict[str, FlagValue]] featureFlagPayloads: Optional[dict[str, Any]] +@dataclass(frozen=True) +class FeatureFlagResult: + """ + The result of calling a feature flag which includes the flag result, variant, and payload. + """ + key: str + enabled: bool + variant: Optional[str] + payload: Optional[Any] + + def get_value(self) -> FlagValue: + """ + Returns the value of the flag. This is the variant if it exists, otherwise the enabled value. + This is the value we report as `$feature_flag_response` in the `$feature_flag_called` event. + """ + return self.variant or self.enabled + + @classmethod + def from_value_and_payload(cls, key: str, value: FlagValue | None, payload: Any) -> "FeatureFlagResult | None": + if value is None: + return None + enabled, variant = (True, value) if isinstance(value, str) else (value, None) + return cls( + key=key, + enabled=enabled, + variant=variant, + payload=payload, + ) + + @classmethod + def from_flag_details(cls, details: FeatureFlag | None) -> "FeatureFlagResult | None": + if details is None: + return None + return cls( + key=details.key, + enabled=details.enabled, + variant=details.variant, + payload=details.metadata.payload, + ) def normalize_flags_response(resp: Any) -> FlagsResponse: """ From 72852202125f444761095f46e7062273aad4109c Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Tue, 22 Apr 2025 15:58:46 -0700 Subject: [PATCH 02/20] Add `get_feature_flag_result` method This provides a "one-shot" method to get both the feature flag result and payload without having to make two decide calls. --- posthog/client.py | 65 ++++++ posthog/test/test_feature_flag_result.py | 260 ++++++++++++++++++++++- 2 files changed, 323 insertions(+), 2 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index 8ada4e1e..d4705f04 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -31,6 +31,7 @@ ) from posthog.types import ( FeatureFlag, + FeatureFlagResult, FlagMetadata, FlagsAndPayloads, FlagsResponse, @@ -939,6 +940,70 @@ def feature_enabled( return None return bool(response) + def get_feature_flag_result( + self, + key, + distinct_id, + *, + groups={}, + person_properties={}, + group_properties={}, + only_evaluate_locally=False, + send_feature_flag_events=True, + disable_geoip=None, + ) -> Optional[FeatureFlagResult]: + """ + Get a FeatureFlagResult object which contains the flag result and payload for a key by evaluating locally or remotely + depending on whether local evaluation is enabled and the flag can be locally evaluated. + + This also captures the $feature_flag_called event unless send_feature_flag_events is False. + """ + require("key", key, string_types) + require("distinct_id", distinct_id, ID_TYPES) + require("groups", groups, dict) + + if self.disabled: + return None + + person_properties, group_properties = self._add_local_person_and_group_properties( + distinct_id, groups, person_properties, group_properties + ) + + flag_result = None + flag_details = None + request_id = None + + flag_value = self._locally_evaluate_flag(key, distinct_id, groups, person_properties, group_properties) + flag_was_locally_evaluated = flag_value is not None + + if flag_was_locally_evaluated: + payload = self._compute_payload_locally(key, flag_value) + flag_result = FeatureFlagResult.from_value_and_payload(key, flag_value, payload) + elif not only_evaluate_locally: + try: + flag_details, request_id = self._get_feature_flag_details_from_decide( + key, distinct_id, groups, person_properties, group_properties, disable_geoip + ) + flag_result = FeatureFlagResult.from_flag_details(flag_details) + self.log.debug(f"Successfully computed flag remotely: #{key} -> #{flag_result}") + except Exception as e: + self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}") + + if send_feature_flag_events: + self._capture_feature_flag_called( + distinct_id, + key, + flag_result.get_value() if flag_result else None, + None, + flag_was_locally_evaluated, + groups, + disable_geoip, + request_id, + flag_details, + ) + + return flag_result + def get_feature_flag( self, key, diff --git a/posthog/test/test_feature_flag_result.py b/posthog/test/test_feature_flag_result.py index 68fe5c87..eca1fdc1 100644 --- a/posthog/test/test_feature_flag_result.py +++ b/posthog/test/test_feature_flag_result.py @@ -1,7 +1,9 @@ import unittest +import mock - +from posthog.client import Client from posthog.types import FeatureFlagResult, FeatureFlag, FlagMetadata, FlagReason +from posthog.test.test_utils import FAKE_TEST_API_KEY class TestFeatureFlagResult(unittest.TestCase): def test_from_bool_value_and_payload(self): @@ -110,4 +112,258 @@ def test_from_flag_details_with_none_payload(self): self.assertEqual(result.key, "test-flag") self.assertEqual(result.enabled, True) self.assertEqual(result.variant, None) - self.assertIsNone(result.payload) \ No newline at end of file + self.assertIsNone(result.payload) + +class TestGetFeatureFlagResult(unittest.TestCase): + @classmethod + def setUpClass(cls): + # This ensures no real HTTP POST requests are made + cls.capture_patch = mock.patch.object(Client, "capture") + cls.capture_patch.start() + + @classmethod + def tearDownClass(cls): + cls.capture_patch.stop() + + def set_fail(self, e, batch): + """Mark the failure handler""" + print("FAIL", e, batch) # noqa: T201 + self.failed = True + + def setUp(self): + self.failed = False + self.client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail) + + @mock.patch.object(Client, "capture") + def test_get_feature_flag_result_boolean_local_evaluation(self, patch_capture): + basic_flag = { + "id": 1, + "name": "Beta Feature", + "key": "person-flag", + "active": True, + "filters": { + "groups": [ + { + "properties": [ + { + "key": "region", + "operator": "exact", + "value": ["USA"], + "type": "person", + } + ], + "rollout_percentage": 100, + } + ], + "payloads": {"true": 300}, + }, + } + self.client.feature_flags = [basic_flag] + + flag_result = self.client.get_feature_flag_result("person-flag", "some-distinct-id", person_properties={"region": "USA"}) + self.assertEqual(flag_result.enabled, True) + self.assertEqual(flag_result.variant, None) + self.assertEqual(flag_result.payload, 300) + patch_capture.assert_called_with( + "some-distinct-id", + "$feature_flag_called", + { + "$feature_flag": "person-flag", + "$feature_flag_response": True, + "locally_evaluated": True, + "$feature/person-flag": True, + }, + groups={}, + disable_geoip=None, + ) + + @mock.patch.object(Client, "capture") + def test_get_feature_flag_result_variant_local_evaluation(self, patch_capture): + basic_flag = { + "id": 1, + "name": "Beta Feature", + "key": "person-flag", + "active": True, + "filters": { + "groups": [ + { + "properties": [ + { + "key": "region", + "operator": "exact", + "value": ["USA"], + "type": "person", + } + ], + "rollout_percentage": 100, + } + ], + "multivariate": { + "variants": [ + {"key": "variant-1", "rollout_percentage": 50}, + {"key": "variant-2", "rollout_percentage": 50}, + ] + }, + "payloads": {"variant-1": '{"some": "value"}'}, + }, + } + self.client.feature_flags = [basic_flag] + + flag_result = self.client.get_feature_flag_result("person-flag", "distinct_id", person_properties={"region": "USA"}) + self.assertEqual(flag_result.enabled, True) + self.assertEqual(flag_result.variant, 'variant-1') + self.assertEqual(flag_result.get_value(), 'variant-1') + self.assertEqual(flag_result.payload, '{"some": "value"}') + + patch_capture.assert_called_with( + "distinct_id", + "$feature_flag_called", + { + "$feature_flag": "person-flag", + "$feature_flag_response": 'variant-1', + "locally_evaluated": True, + "$feature/person-flag": 'variant-1', + }, + groups={}, + disable_geoip=None, + ) + + another_flag_result = self.client.get_feature_flag_result("person-flag", "another-distinct-id", person_properties={"region": "USA"}) + self.assertEqual(another_flag_result.enabled, True) + self.assertEqual(another_flag_result.variant, 'variant-2') + self.assertEqual(another_flag_result.get_value(), 'variant-2') + self.assertIsNone(another_flag_result.payload) + + patch_capture.assert_called_with( + "another-distinct-id", + "$feature_flag_called", + { + "$feature_flag": "person-flag", + "$feature_flag_response": 'variant-2', + "locally_evaluated": True, + "$feature/person-flag": 'variant-2', + }, + groups={}, + disable_geoip=None, + ) + + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_get_feature_flag_result_boolean_decide(self, patch_capture, patch_flags): + patch_flags.return_value = { + "flags": { + "person-flag": { + "key": "person-flag", + "enabled": True, + "variant": None, + "reason": { + "description": "Matched condition set 1", + }, + "metadata": { + "id": 23, + "version": 42, + "payload": '300', + }, + }, + }, + } + + flag_result = self.client.get_feature_flag_result("person-flag", "some-distinct-id") + self.assertEqual(flag_result.enabled, True) + self.assertEqual(flag_result.variant, None) + self.assertEqual(flag_result.payload, '300') + patch_capture.assert_called_with( + "some-distinct-id", + "$feature_flag_called", + { + "$feature_flag": "person-flag", + "$feature_flag_response": True, + "locally_evaluated": False, + "$feature/person-flag": True, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_id": 23, + "$feature_flag_version": 42, + }, + groups={}, + disable_geoip=None, + ) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_get_feature_flag_result_variant_decide(self, patch_capture, patch_flags): + patch_flags.return_value = { + "flags": { + "person-flag": { + "key": "person-flag", + "enabled": True, + "variant": 'variant-1', + "reason": { + "description": "Matched condition set 1", + }, + "metadata": { + "id": 1, + "version": 2, + "payload": '{"some": "value"}', + }, + }, + }, + } + + flag_result = self.client.get_feature_flag_result("person-flag", "distinct_id") + self.assertEqual(flag_result.enabled, True) + self.assertEqual(flag_result.variant, 'variant-1') + self.assertEqual(flag_result.get_value(), 'variant-1') + self.assertEqual(flag_result.payload, '{"some": "value"}') + patch_capture.assert_called_with( + "distinct_id", + "$feature_flag_called", + { + "$feature_flag": "person-flag", + "$feature_flag_response": 'variant-1', + "locally_evaluated": False, + "$feature/person-flag": 'variant-1', + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_id": 1, + "$feature_flag_version": 2, + }, + groups={}, + disable_geoip=None, + ) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_get_feature_flag_result_unknown_flag(self, patch_capture, patch_flags): + patch_flags.return_value = { + "flags": { + "person-flag": { + "key": "person-flag", + "enabled": True, + "variant": None, + "reason": { + "description": "Matched condition set 1", + }, + "metadata": { + "id": 23, + "version": 42, + "payload": '300', + }, + }, + }, + } + + flag_result = self.client.get_feature_flag_result("no-person-flag", "some-distinct-id") + + self.assertIsNone(flag_result) + patch_capture.assert_called_with( + "some-distinct-id", + "$feature_flag_called", + { + "$feature_flag": "no-person-flag", + "$feature_flag_response": None, + "locally_evaluated": False, + "$feature/no-person-flag": None, + }, + groups={}, + disable_geoip=None, + ) \ No newline at end of file From 66b7dad598535af1c5ec9210068fb3ab6928a3c9 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Tue, 22 Apr 2025 16:05:08 -0700 Subject: [PATCH 03/20] Reimplement `get_feature_flag` using `get_feature_flag_result` --- posthog/client.py | 50 ++++++++++------------------------------------- 1 file changed, 10 insertions(+), 40 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index d4705f04..d1fbb4d8 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1023,47 +1023,17 @@ def get_feature_flag( This also captures the $feature_flag_called event unless send_feature_flag_events is False. """ - require("key", key, string_types) - require("distinct_id", distinct_id, ID_TYPES) - require("groups", groups, dict) - - if self.disabled: - return None - - person_properties, group_properties = self._add_local_person_and_group_properties( - distinct_id, groups, person_properties, group_properties + feature_flag_result = self.get_feature_flag_result( + key, + distinct_id, + groups=groups, + person_properties=person_properties, + group_properties=group_properties, + only_evaluate_locally=only_evaluate_locally, + send_feature_flag_events=send_feature_flag_events, + disable_geoip=disable_geoip ) - - response = self._locally_evaluate_flag(key, distinct_id, groups, person_properties, group_properties) - - flag_details = None - request_id = None - - flag_was_locally_evaluated = response is not None - if not flag_was_locally_evaluated and not only_evaluate_locally: - try: - flag_details, request_id = self._get_feature_flag_details_from_decide( - key, distinct_id, groups, person_properties, group_properties, disable_geoip - ) - response = flag_details.get_value() if flag_details else False - self.log.debug(f"Successfully computed flag remotely: #{key} -> #{response}") - except Exception as e: - self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}") - - if send_feature_flag_events: - self._capture_feature_flag_called( - distinct_id, - key, - response or False, - None, - flag_was_locally_evaluated, - groups, - disable_geoip, - request_id, - flag_details, - ) - - return response + return feature_flag_result.get_value() if feature_flag_result else None def _locally_evaluate_flag( self, From ba3c86443fa38e4df3d93ee1cda708aa55efcaab Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Tue, 22 Apr 2025 16:16:54 -0700 Subject: [PATCH 04/20] Pass payload to `$feature_flag_called` event Add private `_get_feature_flag_result` method This will also handle the logic for `get_feature_flag_payload` --- posthog/client.py | 42 +++++++++++++++++++----- posthog/test/test_feature_flag_result.py | 4 +++ 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index d1fbb4d8..addae894 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -939,12 +939,13 @@ def feature_enabled( if response is None: return None return bool(response) - - def get_feature_flag_result( + + def _get_feature_flag_result( self, key, distinct_id, *, + override_match_value=None, groups={}, person_properties={}, group_properties={}, @@ -952,12 +953,6 @@ def get_feature_flag_result( send_feature_flag_events=True, disable_geoip=None, ) -> Optional[FeatureFlagResult]: - """ - Get a FeatureFlagResult object which contains the flag result and payload for a key by evaluating locally or remotely - depending on whether local evaluation is enabled and the flag can be locally evaluated. - - This also captures the $feature_flag_called event unless send_feature_flag_events is False. - """ require("key", key, string_types) require("distinct_id", distinct_id, ID_TYPES) require("groups", groups, dict) @@ -994,7 +989,7 @@ def get_feature_flag_result( distinct_id, key, flag_result.get_value() if flag_result else None, - None, + flag_result.payload if flag_result else None, flag_was_locally_evaluated, groups, disable_geoip, @@ -1004,6 +999,35 @@ def get_feature_flag_result( return flag_result + def get_feature_flag_result( + self, + key, + distinct_id, + *, + groups={}, + person_properties={}, + group_properties={}, + only_evaluate_locally=False, + send_feature_flag_events=True, + disable_geoip=None, + ) -> Optional[FeatureFlagResult]: + """ + Get a FeatureFlagResult object which contains the flag result and payload for a key by evaluating locally or remotely + depending on whether local evaluation is enabled and the flag can be locally evaluated. + + This also captures the $feature_flag_called event unless send_feature_flag_events is False. + """ + return self._get_feature_flag_result( + key, + distinct_id, + groups=groups, + person_properties=person_properties, + group_properties=group_properties, + only_evaluate_locally=only_evaluate_locally, + send_feature_flag_events=send_feature_flag_events, + disable_geoip=disable_geoip, + ) + def get_feature_flag( self, key, diff --git a/posthog/test/test_feature_flag_result.py b/posthog/test/test_feature_flag_result.py index eca1fdc1..1659b741 100644 --- a/posthog/test/test_feature_flag_result.py +++ b/posthog/test/test_feature_flag_result.py @@ -172,6 +172,7 @@ def test_get_feature_flag_result_boolean_local_evaluation(self, patch_capture): "$feature_flag_response": True, "locally_evaluated": True, "$feature/person-flag": True, + "$feature_flag_payload": 300, }, groups={}, disable_geoip=None, @@ -223,6 +224,7 @@ def test_get_feature_flag_result_variant_local_evaluation(self, patch_capture): "$feature_flag_response": 'variant-1', "locally_evaluated": True, "$feature/person-flag": 'variant-1', + "$feature_flag_payload": '{"some": "value"}', }, groups={}, disable_geoip=None, @@ -284,6 +286,7 @@ def test_get_feature_flag_result_boolean_decide(self, patch_capture, patch_flags "$feature_flag_reason": "Matched condition set 1", "$feature_flag_id": 23, "$feature_flag_version": 42, + "$feature_flag_payload": '300', }, groups={}, disable_geoip=None, @@ -326,6 +329,7 @@ def test_get_feature_flag_result_variant_decide(self, patch_capture, patch_flags "$feature_flag_reason": "Matched condition set 1", "$feature_flag_id": 1, "$feature_flag_version": 2, + "$feature_flag_payload": '{"some": "value"}', }, groups={}, disable_geoip=None, From fbe96505209a83c7a7d9be0031b6f5d272017c56 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Tue, 22 Apr 2025 17:05:58 -0700 Subject: [PATCH 05/20] Add ability to override the returned value. --- posthog/test/test_feature_flag_result.py | 75 ++++++++++++++++++++++++ posthog/types.py | 18 +++++- 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/posthog/test/test_feature_flag_result.py b/posthog/test/test_feature_flag_result.py index 1659b741..25b3b10c 100644 --- a/posthog/test/test_feature_flag_result.py +++ b/posthog/test/test_feature_flag_result.py @@ -59,6 +59,81 @@ def test_from_boolean_flag_details(self): self.assertEqual(result.variant, None) self.assertEqual(result.payload, '{"some": "value"}') + def test_from_boolean_flag_details_with_override_variant_match_value(self): + flag_details = FeatureFlag( + key="test-flag", + enabled=True, + variant=None, + metadata=FlagMetadata( + id=1, + version=1, + description="test-flag", + payload='{"some": "value"}' + ), + reason=FlagReason( + code="test-reason", + description="test-reason", + condition_index=0 + ) + ) + + result = FeatureFlagResult.from_flag_details(flag_details, override_match_value="control") + + self.assertEqual(result.key, "test-flag") + self.assertEqual(result.enabled, True) + self.assertEqual(result.variant, "control") + self.assertEqual(result.payload, '{"some": "value"}') + + def test_from_boolean_flag_details_with_override_boolean_match_value(self): + flag_details = FeatureFlag( + key="test-flag", + enabled=True, + variant="control", + metadata=FlagMetadata( + id=1, + version=1, + description="test-flag", + payload='{"some": "value"}' + ), + reason=FlagReason( + code="test-reason", + description="test-reason", + condition_index=0 + ) + ) + + result = FeatureFlagResult.from_flag_details(flag_details, override_match_value=True) + + self.assertEqual(result.key, "test-flag") + self.assertEqual(result.enabled, True) + self.assertEqual(result.variant, None) + self.assertEqual(result.payload, '{"some": "value"}') + + def test_from_boolean_flag_details_with_override_false_match_value(self): + flag_details = FeatureFlag( + key="test-flag", + enabled=True, + variant="control", + metadata=FlagMetadata( + id=1, + version=1, + description="test-flag", + payload='{"some": "value"}' + ), + reason=FlagReason( + code="test-reason", + description="test-reason", + condition_index=0 + ) + ) + + result = FeatureFlagResult.from_flag_details(flag_details, override_match_value=False) + + self.assertEqual(result.key, "test-flag") + self.assertEqual(result.enabled, False) + self.assertEqual(result.variant, None) + self.assertEqual(result.payload, '{"some": "value"}') + def test_from_variant_flag_details(self): flag_details = FeatureFlag( key="test-flag", diff --git a/posthog/types.py b/posthog/types.py index 4a9cd5e0..ffb31f5c 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -131,13 +131,25 @@ def from_value_and_payload(cls, key: str, value: FlagValue | None, payload: Any) ) @classmethod - def from_flag_details(cls, details: FeatureFlag | None) -> "FeatureFlagResult | None": + def from_flag_details(cls, details: FeatureFlag | None, override_match_value: Optional[FlagValue] = None) -> "FeatureFlagResult | None": + """ + Create a FeatureFlagResult from a FeatureFlag object. + + If override_match_value is provided, it will be used to populate the enabled and variant fields. + """ + if details is None: return None + + if override_match_value is not None: + enabled, variant = (True, override_match_value) if isinstance(override_match_value, str) else (override_match_value, None) + else: + enabled, variant = (details.enabled, details.variant) + return cls( key=details.key, - enabled=details.enabled, - variant=details.variant, + enabled=enabled, + variant=variant, payload=details.metadata.payload, ) From ca16b5bcd9f9af0ba99ebd89ed17a56d7ae1808e Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Tue, 22 Apr 2025 17:12:02 -0700 Subject: [PATCH 06/20] Reimplement `get_feature_flag_payload` Now uses the new `_get_feature_flag_result` method. --- posthog/client.py | 60 ++++++++---------------------- posthog/test/test_feature_flags.py | 6 +-- 2 files changed, 17 insertions(+), 49 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index addae894..b64ab702 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -972,14 +972,14 @@ def _get_feature_flag_result( flag_was_locally_evaluated = flag_value is not None if flag_was_locally_evaluated: - payload = self._compute_payload_locally(key, flag_value) - flag_result = FeatureFlagResult.from_value_and_payload(key, flag_value, payload) + payload = self._compute_payload_locally(key, override_match_value or flag_value) + flag_result = FeatureFlagResult.from_value_and_payload(key, override_match_value or flag_value, payload) elif not only_evaluate_locally: try: flag_details, request_id = self._get_feature_flag_details_from_decide( key, distinct_id, groups, person_properties, group_properties, disable_geoip ) - flag_result = FeatureFlagResult.from_flag_details(flag_details) + flag_result = FeatureFlagResult.from_flag_details(flag_details, override_match_value) self.log.debug(f"Successfully computed flag remotely: #{key} -> #{flag_result}") except Exception as e: self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}") @@ -1106,48 +1106,18 @@ def get_feature_flag_payload( send_feature_flag_events=True, disable_geoip=None, ): - if self.disabled: - return None - - if match_value is None: - person_properties, group_properties = self._add_local_person_and_group_properties( - distinct_id, groups, person_properties, group_properties - ) - match_value = self._locally_evaluate_flag(key, distinct_id, groups, person_properties, group_properties) - - response = None - payload = None - flag_details = None - request_id = None - - if match_value is not None: - payload = self._compute_payload_locally(key, match_value) - - flag_was_locally_evaluated = payload is not None - if not flag_was_locally_evaluated and not only_evaluate_locally: - try: - flag_details, request_id = self._get_feature_flag_details_from_decide( - key, distinct_id, groups, person_properties, group_properties, disable_geoip - ) - payload = flag_details.metadata.payload if flag_details else None - response = flag_details.get_value() if flag_details else False - except Exception as e: - self.log.exception(f"[FEATURE FLAGS] Unable to get feature flags and payloads: {e}") - - if send_feature_flag_events: - self._capture_feature_flag_called( - distinct_id, - key, - response or False, - payload, - flag_was_locally_evaluated, - groups, - disable_geoip, - request_id, - flag_details, - ) - - return payload + feature_flag_result = self._get_feature_flag_result( + key, + distinct_id, + override_match_value=match_value, + groups=groups, + person_properties=person_properties, + group_properties=group_properties, + only_evaluate_locally=only_evaluate_locally, + send_feature_flag_events=send_feature_flag_events, + disable_geoip=disable_geoip + ) + return feature_flag_result.payload if feature_flag_result else None def _get_feature_flag_details_from_decide( self, diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index ca2eaddd..0dbd987c 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -2532,8 +2532,7 @@ def test_capture_is_called_in_get_feature_flag_payload(self, patch_flags, patch_ { "$feature_flag": "person-flag", "$feature_flag_response": True, - "$feature_flag_payload": 300, - "locally_evaluated": False, + "locally_evaluated": True, "$feature/person-flag": True, }, groups={}, @@ -2564,8 +2563,7 @@ def test_capture_is_called_in_get_feature_flag_payload(self, patch_flags, patch_ { "$feature_flag": "person-flag", "$feature_flag_response": True, - "$feature_flag_payload": 300, - "locally_evaluated": False, + "locally_evaluated": True, "$feature/person-flag": True, }, groups={}, From e195d213a7ce2e96d99da5c7858cb6c3df0dcf33 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Tue, 22 Apr 2025 17:47:12 -0700 Subject: [PATCH 07/20] Deserialize payloads Fixes #226 --- posthog/client.py | 2 ++ posthog/test/test_feature_flag_result.py | 40 ++++++++++++------------ posthog/test/test_feature_flags.py | 7 +++-- posthog/types.py | 5 +-- 4 files changed, 29 insertions(+), 25 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index b64ab702..9f46a408 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -9,6 +9,7 @@ from datetime import datetime, timedelta from typing import Any, Optional, Union from uuid import UUID, uuid4 +import json import distro # For Linux OS detection from dateutil.tz import tzutc @@ -1160,6 +1161,7 @@ def _capture_feature_flag_called( } if payload: + # if payload is not a string, json serialize it to a string properties["$feature_flag_payload"] = payload if request_id: diff --git a/posthog/test/test_feature_flag_result.py b/posthog/test/test_feature_flag_result.py index 25b3b10c..79b55017 100644 --- a/posthog/test/test_feature_flag_result.py +++ b/posthog/test/test_feature_flag_result.py @@ -7,12 +7,12 @@ class TestFeatureFlagResult(unittest.TestCase): def test_from_bool_value_and_payload(self): - result = FeatureFlagResult.from_value_and_payload("test-flag", True, '{"some": "value"}') + result = FeatureFlagResult.from_value_and_payload("test-flag", True, '[1, 2, 3]') self.assertEqual(result.key, "test-flag") self.assertEqual(result.enabled, True) self.assertEqual(result.variant, None) - self.assertEqual(result.payload, '{"some": "value"}') + self.assertEqual(result.payload, [1, 2, 3]) def test_from_bool_value_and_payload(self): result = FeatureFlagResult.from_value_and_payload("test-flag", False, '{"some": "value"}') @@ -20,15 +20,15 @@ def test_from_bool_value_and_payload(self): self.assertEqual(result.key, "test-flag") self.assertEqual(result.enabled, False) self.assertEqual(result.variant, None) - self.assertEqual(result.payload, '{"some": "value"}') + self.assertEqual(result.payload, {"some": "value"}) def test_from_variant_value_and_payload(self): - result = FeatureFlagResult.from_value_and_payload("test-flag", "control", '{"some": "value"}') + result = FeatureFlagResult.from_value_and_payload("test-flag", "control", 'true') self.assertEqual(result.key, "test-flag") self.assertEqual(result.enabled, True) self.assertEqual(result.variant, "control") - self.assertEqual(result.payload, '{"some": "value"}') + self.assertEqual(result.payload, True) def test_from_none_value_and_payload(self): result = FeatureFlagResult.from_value_and_payload("test-flag", None, '{"some": "value"}') @@ -43,7 +43,7 @@ def test_from_boolean_flag_details(self): id=1, version=1, description="test-flag", - payload='{"some": "value"}' + payload='"Some string"' ), reason=FlagReason( code="test-reason", @@ -57,7 +57,7 @@ def test_from_boolean_flag_details(self): self.assertEqual(result.key, "test-flag") self.assertEqual(result.enabled, True) self.assertEqual(result.variant, None) - self.assertEqual(result.payload, '{"some": "value"}') + self.assertEqual(result.payload, "Some string") def test_from_boolean_flag_details_with_override_variant_match_value(self): flag_details = FeatureFlag( @@ -68,7 +68,7 @@ def test_from_boolean_flag_details_with_override_variant_match_value(self): id=1, version=1, description="test-flag", - payload='{"some": "value"}' + payload='"Some string"' ), reason=FlagReason( code="test-reason", @@ -82,7 +82,7 @@ def test_from_boolean_flag_details_with_override_variant_match_value(self): self.assertEqual(result.key, "test-flag") self.assertEqual(result.enabled, True) self.assertEqual(result.variant, "control") - self.assertEqual(result.payload, '{"some": "value"}') + self.assertEqual(result.payload, "Some string") def test_from_boolean_flag_details_with_override_boolean_match_value(self): flag_details = FeatureFlag( @@ -107,7 +107,7 @@ def test_from_boolean_flag_details_with_override_boolean_match_value(self): self.assertEqual(result.key, "test-flag") self.assertEqual(result.enabled, True) self.assertEqual(result.variant, None) - self.assertEqual(result.payload, '{"some": "value"}') + self.assertEqual(result.payload, {"some": "value"}) def test_from_boolean_flag_details_with_override_false_match_value(self): flag_details = FeatureFlag( @@ -132,7 +132,7 @@ def test_from_boolean_flag_details_with_override_false_match_value(self): self.assertEqual(result.key, "test-flag") self.assertEqual(result.enabled, False) self.assertEqual(result.variant, None) - self.assertEqual(result.payload, '{"some": "value"}') + self.assertEqual(result.payload, {"some": "value"}) def test_from_variant_flag_details(self): flag_details = FeatureFlag( @@ -157,7 +157,7 @@ def test_from_variant_flag_details(self): self.assertEqual(result.key, "test-flag") self.assertEqual(result.enabled, True) self.assertEqual(result.variant, "control") - self.assertEqual(result.payload, '{"some": "value"}') + self.assertEqual(result.payload, {"some": "value"}) def test_from_none_flag_details(self): result = FeatureFlagResult.from_flag_details(None) @@ -230,7 +230,7 @@ def test_get_feature_flag_result_boolean_local_evaluation(self, patch_capture): "rollout_percentage": 100, } ], - "payloads": {"true": 300}, + "payloads": {"true": '300'}, }, } self.client.feature_flags = [basic_flag] @@ -289,7 +289,7 @@ def test_get_feature_flag_result_variant_local_evaluation(self, patch_capture): self.assertEqual(flag_result.enabled, True) self.assertEqual(flag_result.variant, 'variant-1') self.assertEqual(flag_result.get_value(), 'variant-1') - self.assertEqual(flag_result.payload, '{"some": "value"}') + self.assertEqual(flag_result.payload, {"some": "value"}) patch_capture.assert_called_with( "distinct_id", @@ -299,7 +299,7 @@ def test_get_feature_flag_result_variant_local_evaluation(self, patch_capture): "$feature_flag_response": 'variant-1', "locally_evaluated": True, "$feature/person-flag": 'variant-1', - "$feature_flag_payload": '{"some": "value"}', + "$feature_flag_payload": {"some": "value"}, }, groups={}, disable_geoip=None, @@ -349,7 +349,7 @@ def test_get_feature_flag_result_boolean_decide(self, patch_capture, patch_flags flag_result = self.client.get_feature_flag_result("person-flag", "some-distinct-id") self.assertEqual(flag_result.enabled, True) self.assertEqual(flag_result.variant, None) - self.assertEqual(flag_result.payload, '300') + self.assertEqual(flag_result.payload, 300) patch_capture.assert_called_with( "some-distinct-id", "$feature_flag_called", @@ -361,7 +361,7 @@ def test_get_feature_flag_result_boolean_decide(self, patch_capture, patch_flags "$feature_flag_reason": "Matched condition set 1", "$feature_flag_id": 23, "$feature_flag_version": 42, - "$feature_flag_payload": '300', + "$feature_flag_payload": 300, }, groups={}, disable_geoip=None, @@ -382,7 +382,7 @@ def test_get_feature_flag_result_variant_decide(self, patch_capture, patch_flags "metadata": { "id": 1, "version": 2, - "payload": '{"some": "value"}', + "payload": '[1, 2, 3]', }, }, }, @@ -392,7 +392,7 @@ def test_get_feature_flag_result_variant_decide(self, patch_capture, patch_flags self.assertEqual(flag_result.enabled, True) self.assertEqual(flag_result.variant, 'variant-1') self.assertEqual(flag_result.get_value(), 'variant-1') - self.assertEqual(flag_result.payload, '{"some": "value"}') + self.assertEqual(flag_result.payload, [1, 2, 3]) patch_capture.assert_called_with( "distinct_id", "$feature_flag_called", @@ -404,7 +404,7 @@ def test_get_feature_flag_result_variant_decide(self, patch_capture, patch_flags "$feature_flag_reason": "Matched condition set 1", "$feature_flag_id": 1, "$feature_flag_version": 2, - "$feature_flag_payload": '{"some": "value"}', + "$feature_flag_payload": [1, 2, 3], }, groups={}, disable_geoip=None, diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 0dbd987c..6a13af20 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -1686,7 +1686,7 @@ def test_multivariate_feature_flag_payloads(self, patch_flags): {"key": "third-variant", "name": "Third Variant", "rollout_percentage": 25}, ] }, - "payloads": {"first-variant": "some-payload", "third-variant": {"a": "json"}}, + "payloads": {"first-variant": '"some-payload"', "third-variant": '{"a": "json"}'}, }, } self.client.feature_flags = [multivariate_flag] @@ -2423,7 +2423,8 @@ def test_capture_is_called_with_flag_details_and_payload(self, patch_flags, patc client = Client(FAKE_TEST_API_KEY) self.assertEqual( - client.get_feature_flag_payload("decide-flag-with-payload", "some-distinct-id"), '{"foo": "bar"}' + client.get_feature_flag_payload("decide-flag-with-payload", "some-distinct-id"), + {"foo": "bar"} ) self.assertEqual(patch_capture.call_count, 1) patch_capture.assert_called_with( @@ -2438,7 +2439,7 @@ def test_capture_is_called_with_flag_details_and_payload(self, patch_flags, patc "$feature_flag_id": 23, "$feature_flag_version": 42, "$feature_flag_request_id": "18043bf7-9cf6-44cd-b959-9662ee20d371", - "$feature_flag_payload": '{"foo": "bar"}', + "$feature_flag_payload": {"foo": "bar"}, }, groups={}, disable_geoip=None, diff --git a/posthog/types.py b/posthog/types.py index ffb31f5c..139a6ef3 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from typing import Any, List, Optional, TypedDict, Union, cast +import json FlagValue = Union[bool, str] @@ -127,7 +128,7 @@ def from_value_and_payload(cls, key: str, value: FlagValue | None, payload: Any) key=key, enabled=enabled, variant=variant, - payload=payload, + payload=json.loads(payload) if isinstance(payload, str) else payload, ) @classmethod @@ -150,7 +151,7 @@ def from_flag_details(cls, details: FeatureFlag | None, override_match_value: Op key=details.key, enabled=enabled, variant=variant, - payload=details.metadata.payload, + payload=json.loads(details.metadata.payload) if isinstance(details.metadata.payload, str) else details.metadata.payload, ) def normalize_flags_response(resp: Any) -> FlagsResponse: From 575a6b45e0f10976db1bea51c66e4975ef83a2b4 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Wed, 23 Apr 2025 10:41:40 -0700 Subject: [PATCH 08/20] Bump changelog and version --- CHANGELOG.md | 18 ++++++++++++++++++ posthog/version.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2e07bab..544c9371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +## 4.0.0 - 2025-04-23 + +1. Added new method `get_feature_flag_result` which returns a `FeatureFlagResult` object. This object breaks down the result of a feature flag into its enabled state, variant, and payload. The benefit of this method is it allows you to retrieve the result of a feature flag and its payload in a single call. You can call `get_value` on the result to get the value of the feature flag, which is the same value returned by `get_feature_flag` (aka the string `variant` if the flag is a multivariate flag or the `boolean` value if the flag is a boolean flag). + +Example: + +```python +result = posthog.get_feature_flag_result("my-flag", "distinct_id") +print(result.enabled) # True or False +print(result.variant) # 'the-variant-value' or None +print(result.payload) # {'foo': 'bar'} +print(result.get_value()) # 'the-variant-value' or True or False +``` + +Breaking change: + +1. `get_feature_flag_payload` now deserializes payloads from JSON strings to `Any`. Previously, it returned the payload as a JSON encoded string. + ## 3.25.0 – 2025-04-15 1. Roll out new `/flags` endpoint to 100% of `/decide` traffic, excluding the top 10 customers. diff --git a/posthog/version.py b/posthog/version.py index 19441834..7a134c7a 100644 --- a/posthog/version.py +++ b/posthog/version.py @@ -1,4 +1,4 @@ -VERSION = "3.25.0" +VERSION = "4.0.0" if __name__ == "__main__": print(VERSION, end="") # noqa: T201 From 421c25ca86c85c4d55a513dcb877d02f83fa23e8 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Wed, 23 Apr 2025 10:44:28 -0700 Subject: [PATCH 09/20] Update CHANGELOG.md --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 544c9371..baff2430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,18 @@ Breaking change: 1. `get_feature_flag_payload` now deserializes payloads from JSON strings to `Any`. Previously, it returned the payload as a JSON encoded string. +Before: + +```python +payload = get_feature_flag_payload('key', 'distinct_id') # "{\"some\": \"payload\"}" +``` + +After: + +```python +payload = get_feature_flag_payload('key', 'distinct_id') # {"some": "payload"} +``` + ## 3.25.0 – 2025-04-15 1. Roll out new `/flags` endpoint to 100% of `/decide` traffic, excluding the top 10 customers. From d14e31170578175098903bff319bfa230a824262 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Wed, 23 Apr 2025 10:45:28 -0700 Subject: [PATCH 10/20] Clarify --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index baff2430..db11e1d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## 4.0.0 - 2025-04-23 -1. Added new method `get_feature_flag_result` which returns a `FeatureFlagResult` object. This object breaks down the result of a feature flag into its enabled state, variant, and payload. The benefit of this method is it allows you to retrieve the result of a feature flag and its payload in a single call. You can call `get_value` on the result to get the value of the feature flag, which is the same value returned by `get_feature_flag` (aka the string `variant` if the flag is a multivariate flag or the `boolean` value if the flag is a boolean flag). +1. Added new method `get_feature_flag_result` which returns a `FeatureFlagResult` object. This object breaks down the result of a feature flag into its enabled state, variant, and payload. The benefit of this method is it allows you to retrieve the result of a feature flag and its payload in a single API call. You can call `get_value` on the result to get the value of the feature flag, which is the same value returned by `get_feature_flag` (aka the string `variant` if the flag is a multivariate flag or the `boolean` value if the flag is a boolean flag). Example: From 558f67742bf8970c0886c0171a22a501328b3773 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Wed, 23 Apr 2025 11:25:49 -0700 Subject: [PATCH 11/20] Fix test name Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- posthog/test/test_feature_flag_result.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/posthog/test/test_feature_flag_result.py b/posthog/test/test_feature_flag_result.py index 79b55017..57b12bff 100644 --- a/posthog/test/test_feature_flag_result.py +++ b/posthog/test/test_feature_flag_result.py @@ -14,7 +14,7 @@ def test_from_bool_value_and_payload(self): self.assertEqual(result.variant, None) self.assertEqual(result.payload, [1, 2, 3]) - def test_from_bool_value_and_payload(self): + def test_from_false_value_and_payload(self): result = FeatureFlagResult.from_value_and_payload("test-flag", False, '{"some": "value"}') self.assertEqual(result.key, "test-flag") From b75ed736e02461d817d7339171b15b8979af6559 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Wed, 23 Apr 2025 11:31:18 -0700 Subject: [PATCH 12/20] Format with `black .` --- posthog/client.py | 32 +++--- posthog/test/test_feature_flag_result.py | 137 ++++++++--------------- posthog/test/test_feature_flags.py | 3 +- posthog/types.py | 23 +++- 4 files changed, 79 insertions(+), 116 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index 9f46a408..279df4e9 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -940,7 +940,7 @@ def feature_enabled( if response is None: return None return bool(response) - + def _get_feature_flag_result( self, key, @@ -1049,14 +1049,14 @@ def get_feature_flag( This also captures the $feature_flag_called event unless send_feature_flag_events is False. """ feature_flag_result = self.get_feature_flag_result( - key, - distinct_id, - groups=groups, - person_properties=person_properties, - group_properties=group_properties, - only_evaluate_locally=only_evaluate_locally, - send_feature_flag_events=send_feature_flag_events, - disable_geoip=disable_geoip + key, + distinct_id, + groups=groups, + person_properties=person_properties, + group_properties=group_properties, + only_evaluate_locally=only_evaluate_locally, + send_feature_flag_events=send_feature_flag_events, + disable_geoip=disable_geoip, ) return feature_flag_result.get_value() if feature_flag_result else None @@ -1108,15 +1108,15 @@ def get_feature_flag_payload( disable_geoip=None, ): feature_flag_result = self._get_feature_flag_result( - key, + key, distinct_id, override_match_value=match_value, - groups=groups, - person_properties=person_properties, - group_properties=group_properties, - only_evaluate_locally=only_evaluate_locally, - send_feature_flag_events=send_feature_flag_events, - disable_geoip=disable_geoip + groups=groups, + person_properties=person_properties, + group_properties=group_properties, + only_evaluate_locally=only_evaluate_locally, + send_feature_flag_events=send_feature_flag_events, + disable_geoip=disable_geoip, ) return feature_flag_result.payload if feature_flag_result else None diff --git a/posthog/test/test_feature_flag_result.py b/posthog/test/test_feature_flag_result.py index 57b12bff..85e40cb8 100644 --- a/posthog/test/test_feature_flag_result.py +++ b/posthog/test/test_feature_flag_result.py @@ -5,9 +5,10 @@ from posthog.types import FeatureFlagResult, FeatureFlag, FlagMetadata, FlagReason from posthog.test.test_utils import FAKE_TEST_API_KEY + class TestFeatureFlagResult(unittest.TestCase): def test_from_bool_value_and_payload(self): - result = FeatureFlagResult.from_value_and_payload("test-flag", True, '[1, 2, 3]') + result = FeatureFlagResult.from_value_and_payload("test-flag", True, "[1, 2, 3]") self.assertEqual(result.key, "test-flag") self.assertEqual(result.enabled, True) @@ -21,9 +22,9 @@ def test_from_false_value_and_payload(self): self.assertEqual(result.enabled, False) self.assertEqual(result.variant, None) self.assertEqual(result.payload, {"some": "value"}) - + def test_from_variant_value_and_payload(self): - result = FeatureFlagResult.from_value_and_payload("test-flag", "control", 'true') + result = FeatureFlagResult.from_value_and_payload("test-flag", "control", "true") self.assertEqual(result.key, "test-flag") self.assertEqual(result.enabled, True) @@ -39,17 +40,8 @@ def test_from_boolean_flag_details(self): key="test-flag", enabled=True, variant=None, - metadata=FlagMetadata( - id=1, - version=1, - description="test-flag", - payload='"Some string"' - ), - reason=FlagReason( - code="test-reason", - description="test-reason", - condition_index=0 - ) + metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='"Some string"'), + reason=FlagReason(code="test-reason", description="test-reason", condition_index=0), ) result = FeatureFlagResult.from_flag_details(flag_details) @@ -64,17 +56,8 @@ def test_from_boolean_flag_details_with_override_variant_match_value(self): key="test-flag", enabled=True, variant=None, - metadata=FlagMetadata( - id=1, - version=1, - description="test-flag", - payload='"Some string"' - ), - reason=FlagReason( - code="test-reason", - description="test-reason", - condition_index=0 - ) + metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='"Some string"'), + reason=FlagReason(code="test-reason", description="test-reason", condition_index=0), ) result = FeatureFlagResult.from_flag_details(flag_details, override_match_value="control") @@ -89,17 +72,8 @@ def test_from_boolean_flag_details_with_override_boolean_match_value(self): key="test-flag", enabled=True, variant="control", - metadata=FlagMetadata( - id=1, - version=1, - description="test-flag", - payload='{"some": "value"}' - ), - reason=FlagReason( - code="test-reason", - description="test-reason", - condition_index=0 - ) + metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='{"some": "value"}'), + reason=FlagReason(code="test-reason", description="test-reason", condition_index=0), ) result = FeatureFlagResult.from_flag_details(flag_details, override_match_value=True) @@ -114,17 +88,8 @@ def test_from_boolean_flag_details_with_override_false_match_value(self): key="test-flag", enabled=True, variant="control", - metadata=FlagMetadata( - id=1, - version=1, - description="test-flag", - payload='{"some": "value"}' - ), - reason=FlagReason( - code="test-reason", - description="test-reason", - condition_index=0 - ) + metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='{"some": "value"}'), + reason=FlagReason(code="test-reason", description="test-reason", condition_index=0), ) result = FeatureFlagResult.from_flag_details(flag_details, override_match_value=False) @@ -139,17 +104,8 @@ def test_from_variant_flag_details(self): key="test-flag", enabled=True, variant="control", - metadata=FlagMetadata( - id=1, - version=1, - description="test-flag", - payload='{"some": "value"}' - ), - reason=FlagReason( - code="test-reason", - description="test-reason", - condition_index=0 - ) + metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='{"some": "value"}'), + reason=FlagReason(code="test-reason", description="test-reason", condition_index=0), ) result = FeatureFlagResult.from_flag_details(flag_details) @@ -169,17 +125,8 @@ def test_from_flag_details_with_none_payload(self): key="test-flag", enabled=True, variant=None, - metadata=FlagMetadata( - id=1, - version=1, - description="test-flag", - payload=None - ), - reason=FlagReason( - code="test-reason", - description="test-reason", - condition_index=0 - ) + metadata=FlagMetadata(id=1, version=1, description="test-flag", payload=None), + reason=FlagReason(code="test-reason", description="test-reason", condition_index=0), ) result = FeatureFlagResult.from_flag_details(flag_details) @@ -189,6 +136,7 @@ def test_from_flag_details_with_none_payload(self): self.assertEqual(result.variant, None) self.assertIsNone(result.payload) + class TestGetFeatureFlagResult(unittest.TestCase): @classmethod def setUpClass(cls): @@ -230,12 +178,14 @@ def test_get_feature_flag_result_boolean_local_evaluation(self, patch_capture): "rollout_percentage": 100, } ], - "payloads": {"true": '300'}, + "payloads": {"true": "300"}, }, } self.client.feature_flags = [basic_flag] - flag_result = self.client.get_feature_flag_result("person-flag", "some-distinct-id", person_properties={"region": "USA"}) + flag_result = self.client.get_feature_flag_result( + "person-flag", "some-distinct-id", person_properties={"region": "USA"} + ) self.assertEqual(flag_result.enabled, True) self.assertEqual(flag_result.variant, None) self.assertEqual(flag_result.payload, 300) @@ -285,10 +235,12 @@ def test_get_feature_flag_result_variant_local_evaluation(self, patch_capture): } self.client.feature_flags = [basic_flag] - flag_result = self.client.get_feature_flag_result("person-flag", "distinct_id", person_properties={"region": "USA"}) + flag_result = self.client.get_feature_flag_result( + "person-flag", "distinct_id", person_properties={"region": "USA"} + ) self.assertEqual(flag_result.enabled, True) - self.assertEqual(flag_result.variant, 'variant-1') - self.assertEqual(flag_result.get_value(), 'variant-1') + self.assertEqual(flag_result.variant, "variant-1") + self.assertEqual(flag_result.get_value(), "variant-1") self.assertEqual(flag_result.payload, {"some": "value"}) patch_capture.assert_called_with( @@ -296,19 +248,21 @@ def test_get_feature_flag_result_variant_local_evaluation(self, patch_capture): "$feature_flag_called", { "$feature_flag": "person-flag", - "$feature_flag_response": 'variant-1', + "$feature_flag_response": "variant-1", "locally_evaluated": True, - "$feature/person-flag": 'variant-1', + "$feature/person-flag": "variant-1", "$feature_flag_payload": {"some": "value"}, }, groups={}, disable_geoip=None, ) - another_flag_result = self.client.get_feature_flag_result("person-flag", "another-distinct-id", person_properties={"region": "USA"}) + another_flag_result = self.client.get_feature_flag_result( + "person-flag", "another-distinct-id", person_properties={"region": "USA"} + ) self.assertEqual(another_flag_result.enabled, True) - self.assertEqual(another_flag_result.variant, 'variant-2') - self.assertEqual(another_flag_result.get_value(), 'variant-2') + self.assertEqual(another_flag_result.variant, "variant-2") + self.assertEqual(another_flag_result.get_value(), "variant-2") self.assertIsNone(another_flag_result.payload) patch_capture.assert_called_with( @@ -316,15 +270,14 @@ def test_get_feature_flag_result_variant_local_evaluation(self, patch_capture): "$feature_flag_called", { "$feature_flag": "person-flag", - "$feature_flag_response": 'variant-2', + "$feature_flag_response": "variant-2", "locally_evaluated": True, - "$feature/person-flag": 'variant-2', + "$feature/person-flag": "variant-2", }, groups={}, disable_geoip=None, ) - @mock.patch("posthog.client.flags") @mock.patch.object(Client, "capture") def test_get_feature_flag_result_boolean_decide(self, patch_capture, patch_flags): @@ -340,7 +293,7 @@ def test_get_feature_flag_result_boolean_decide(self, patch_capture, patch_flags "metadata": { "id": 23, "version": 42, - "payload": '300', + "payload": "300", }, }, }, @@ -375,14 +328,14 @@ def test_get_feature_flag_result_variant_decide(self, patch_capture, patch_flags "person-flag": { "key": "person-flag", "enabled": True, - "variant": 'variant-1', + "variant": "variant-1", "reason": { "description": "Matched condition set 1", }, "metadata": { "id": 1, "version": 2, - "payload": '[1, 2, 3]', + "payload": "[1, 2, 3]", }, }, }, @@ -390,17 +343,17 @@ def test_get_feature_flag_result_variant_decide(self, patch_capture, patch_flags flag_result = self.client.get_feature_flag_result("person-flag", "distinct_id") self.assertEqual(flag_result.enabled, True) - self.assertEqual(flag_result.variant, 'variant-1') - self.assertEqual(flag_result.get_value(), 'variant-1') + self.assertEqual(flag_result.variant, "variant-1") + self.assertEqual(flag_result.get_value(), "variant-1") self.assertEqual(flag_result.payload, [1, 2, 3]) patch_capture.assert_called_with( "distinct_id", "$feature_flag_called", { "$feature_flag": "person-flag", - "$feature_flag_response": 'variant-1', + "$feature_flag_response": "variant-1", "locally_evaluated": False, - "$feature/person-flag": 'variant-1', + "$feature/person-flag": "variant-1", "$feature_flag_reason": "Matched condition set 1", "$feature_flag_id": 1, "$feature_flag_version": 2, @@ -425,14 +378,14 @@ def test_get_feature_flag_result_unknown_flag(self, patch_capture, patch_flags): "metadata": { "id": 23, "version": 42, - "payload": '300', + "payload": "300", }, }, }, } flag_result = self.client.get_feature_flag_result("no-person-flag", "some-distinct-id") - + self.assertIsNone(flag_result) patch_capture.assert_called_with( "some-distinct-id", @@ -445,4 +398,4 @@ def test_get_feature_flag_result_unknown_flag(self, patch_capture, patch_flags): }, groups={}, disable_geoip=None, - ) \ No newline at end of file + ) diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 6a13af20..d18af7ef 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -2423,8 +2423,7 @@ def test_capture_is_called_with_flag_details_and_payload(self, patch_flags, patc client = Client(FAKE_TEST_API_KEY) self.assertEqual( - client.get_feature_flag_payload("decide-flag-with-payload", "some-distinct-id"), - {"foo": "bar"} + client.get_feature_flag_payload("decide-flag-with-payload", "some-distinct-id"), {"foo": "bar"} ) self.assertEqual(patch_capture.call_count, 1) patch_capture.assert_called_with( diff --git a/posthog/types.py b/posthog/types.py index 139a6ef3..8fcc5d19 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -102,11 +102,13 @@ class FlagsAndPayloads(TypedDict, total=True): featureFlags: Optional[dict[str, FlagValue]] featureFlagPayloads: Optional[dict[str, Any]] + @dataclass(frozen=True) class FeatureFlagResult: """ The result of calling a feature flag which includes the flag result, variant, and payload. """ + key: str enabled: bool variant: Optional[str] @@ -118,7 +120,7 @@ def get_value(self) -> FlagValue: This is the value we report as `$feature_flag_response` in the `$feature_flag_called` event. """ return self.variant or self.enabled - + @classmethod def from_value_and_payload(cls, key: str, value: FlagValue | None, payload: Any) -> "FeatureFlagResult | None": if value is None: @@ -130,9 +132,11 @@ def from_value_and_payload(cls, key: str, value: FlagValue | None, payload: Any) variant=variant, payload=json.loads(payload) if isinstance(payload, str) else payload, ) - + @classmethod - def from_flag_details(cls, details: FeatureFlag | None, override_match_value: Optional[FlagValue] = None) -> "FeatureFlagResult | None": + def from_flag_details( + cls, details: FeatureFlag | None, override_match_value: Optional[FlagValue] = None + ) -> "FeatureFlagResult | None": """ Create a FeatureFlagResult from a FeatureFlag object. @@ -141,9 +145,11 @@ def from_flag_details(cls, details: FeatureFlag | None, override_match_value: Op if details is None: return None - + if override_match_value is not None: - enabled, variant = (True, override_match_value) if isinstance(override_match_value, str) else (override_match_value, None) + enabled, variant = ( + (True, override_match_value) if isinstance(override_match_value, str) else (override_match_value, None) + ) else: enabled, variant = (details.enabled, details.variant) @@ -151,9 +157,14 @@ def from_flag_details(cls, details: FeatureFlag | None, override_match_value: Op key=details.key, enabled=enabled, variant=variant, - payload=json.loads(details.metadata.payload) if isinstance(details.metadata.payload, str) else details.metadata.payload, + payload=( + json.loads(details.metadata.payload) + if isinstance(details.metadata.payload, str) + else details.metadata.payload + ), ) + def normalize_flags_response(resp: Any) -> FlagsResponse: """ Normalize the response from the decide or flags API endpoint into a FlagsResponse. From 66fdf0710d1dbe23f56f2627cf51bc57cd2031c3 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Wed, 23 Apr 2025 11:33:52 -0700 Subject: [PATCH 13/20] Fix python 3.9 type error --- posthog/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/posthog/types.py b/posthog/types.py index 8fcc5d19..a8d5fa79 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -122,7 +122,7 @@ def get_value(self) -> FlagValue: return self.variant or self.enabled @classmethod - def from_value_and_payload(cls, key: str, value: FlagValue | None, payload: Any) -> "FeatureFlagResult | None": + def from_value_and_payload(cls, key: str, value: Union[FlagValue, None], payload: Any) -> Union["FeatureFlagResult", None]: if value is None: return None enabled, variant = (True, value) if isinstance(value, str) else (value, None) From affa37b6f9e19589a00a5e5dc07a9519eb533924 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Thu, 24 Apr 2025 08:51:59 -0700 Subject: [PATCH 14/20] Add `reason` to the `FeatureFlagResult` --- posthog/types.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/posthog/types.py b/posthog/types.py index a8d5fa79..c29aa287 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -113,6 +113,7 @@ class FeatureFlagResult: enabled: bool variant: Optional[str] payload: Optional[Any] + reason: Optional[str] def get_value(self) -> FlagValue: """ @@ -131,6 +132,7 @@ def from_value_and_payload(cls, key: str, value: Union[FlagValue, None], payload enabled=enabled, variant=variant, payload=json.loads(payload) if isinstance(payload, str) else payload, + reason=None, ) @classmethod @@ -162,6 +164,7 @@ def from_flag_details( if isinstance(details.metadata.payload, str) else details.metadata.payload ), + reason=details.reason.description if details.reason else None, ) From 8b9032aac9cec19de1c6a75f183dd46c2846e7e5 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Thu, 24 Apr 2025 08:53:10 -0700 Subject: [PATCH 15/20] Add some type documentation --- CHANGELOG.md | 1 + posthog/types.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db11e1d8..605a868d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ print(result.enabled) # True or False print(result.variant) # 'the-variant-value' or None print(result.payload) # {'foo': 'bar'} print(result.get_value()) # 'the-variant-value' or True or False +print(result.reason) # 'matched condition set 2' (Not available for local evaluation) ``` Breaking change: diff --git a/posthog/types.py b/posthog/types.py index c29aa287..9e790e62 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -107,6 +107,13 @@ class FlagsAndPayloads(TypedDict, total=True): class FeatureFlagResult: """ The result of calling a feature flag which includes the flag result, variant, and payload. + + Attributes: + key (str): The unique identifier of the feature flag. + enabled (bool): Whether the feature flag is enabled for the current context. + variant (Optional[str]): The variant value if the flag is enabled and has variants, None otherwise. + payload (Optional[Any]): Additional data associated with the feature flag, if any. + reason (Optional[str]): A description of why the flag was enabled or disabled, if available. """ key: str @@ -119,11 +126,25 @@ def get_value(self) -> FlagValue: """ Returns the value of the flag. This is the variant if it exists, otherwise the enabled value. This is the value we report as `$feature_flag_response` in the `$feature_flag_called` event. + + Returns: + FlagValue: Either a string variant or boolean value representing the flag's state. """ return self.variant or self.enabled @classmethod def from_value_and_payload(cls, key: str, value: Union[FlagValue, None], payload: Any) -> Union["FeatureFlagResult", None]: + """ + Creates a FeatureFlagResult from a flag value and payload. + + Args: + key (str): The unique identifier of the feature flag. + value (Union[FlagValue, None]): The value of the flag (string variant or boolean). + payload (Any): Additional data associated with the feature flag. + + Returns: + Union[FeatureFlagResult, None]: A new FeatureFlagResult instance, or None if value is None. + """ if value is None: return None enabled, variant = (True, value) if isinstance(value, str) else (value, None) @@ -142,7 +163,13 @@ def from_flag_details( """ Create a FeatureFlagResult from a FeatureFlag object. - If override_match_value is provided, it will be used to populate the enabled and variant fields. + Args: + details (FeatureFlag | None): The FeatureFlag object to convert. + override_match_value (Optional[FlagValue]): If provided, this value will be used to populate + the enabled and variant fields instead of the values from the FeatureFlag. + + Returns: + FeatureFlagResult | None: A new FeatureFlagResult instance, or None if details is None. """ if details is None: From b011d60ee2117fa029fdb93a679028f2c80d3cf9 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Thu, 24 Apr 2025 08:53:37 -0700 Subject: [PATCH 16/20] Bump date --- CHANGELOG.md | 2 +- posthog/client.py | 1 - posthog/test/test_feature_flag_result.py | 3 ++- posthog/types.py | 16 +++++++++------- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 605a868d..d73c9b0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## 4.0.0 - 2025-04-23 +## 4.0.0 - 2025-04-24 1. Added new method `get_feature_flag_result` which returns a `FeatureFlagResult` object. This object breaks down the result of a feature flag into its enabled state, variant, and payload. The benefit of this method is it allows you to retrieve the result of a feature flag and its payload in a single API call. You can call `get_value` on the result to get the value of the feature flag, which is the same value returned by `get_feature_flag` (aka the string `variant` if the flag is a multivariate flag or the `boolean` value if the flag is a boolean flag). diff --git a/posthog/client.py b/posthog/client.py index 279df4e9..cb1e7361 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -9,7 +9,6 @@ from datetime import datetime, timedelta from typing import Any, Optional, Union from uuid import UUID, uuid4 -import json import distro # For Linux OS detection from dateutil.tz import tzutc diff --git a/posthog/test/test_feature_flag_result.py b/posthog/test/test_feature_flag_result.py index 85e40cb8..8614e2cc 100644 --- a/posthog/test/test_feature_flag_result.py +++ b/posthog/test/test_feature_flag_result.py @@ -1,9 +1,10 @@ import unittest + import mock from posthog.client import Client -from posthog.types import FeatureFlagResult, FeatureFlag, FlagMetadata, FlagReason from posthog.test.test_utils import FAKE_TEST_API_KEY +from posthog.types import FeatureFlag, FeatureFlagResult, FlagMetadata, FlagReason class TestFeatureFlagResult(unittest.TestCase): diff --git a/posthog/types.py b/posthog/types.py index 9e790e62..5e041eb4 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -1,6 +1,6 @@ +import json from dataclasses import dataclass from typing import Any, List, Optional, TypedDict, Union, cast -import json FlagValue = Union[bool, str] @@ -107,7 +107,7 @@ class FlagsAndPayloads(TypedDict, total=True): class FeatureFlagResult: """ The result of calling a feature flag which includes the flag result, variant, and payload. - + Attributes: key (str): The unique identifier of the feature flag. enabled (bool): Whether the feature flag is enabled for the current context. @@ -126,22 +126,24 @@ def get_value(self) -> FlagValue: """ Returns the value of the flag. This is the variant if it exists, otherwise the enabled value. This is the value we report as `$feature_flag_response` in the `$feature_flag_called` event. - + Returns: FlagValue: Either a string variant or boolean value representing the flag's state. """ return self.variant or self.enabled @classmethod - def from_value_and_payload(cls, key: str, value: Union[FlagValue, None], payload: Any) -> Union["FeatureFlagResult", None]: + def from_value_and_payload( + cls, key: str, value: Union[FlagValue, None], payload: Any + ) -> Union["FeatureFlagResult", None]: """ Creates a FeatureFlagResult from a flag value and payload. - + Args: key (str): The unique identifier of the feature flag. value (Union[FlagValue, None]): The value of the flag (string variant or boolean). payload (Any): Additional data associated with the feature flag. - + Returns: Union[FeatureFlagResult, None]: A new FeatureFlagResult instance, or None if value is None. """ @@ -165,7 +167,7 @@ def from_flag_details( Args: details (FeatureFlag | None): The FeatureFlag object to convert. - override_match_value (Optional[FlagValue]): If provided, this value will be used to populate + override_match_value (Optional[FlagValue]): If provided, this value will be used to populate the enabled and variant fields instead of the values from the FeatureFlag. Returns: From d972ac74c149a297e5b11f8bf6b08d71b2668880 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Thu, 24 Apr 2025 09:40:06 -0700 Subject: [PATCH 17/20] Use Union types Supporting old Python means we can't have nice things. --- posthog/types.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/posthog/types.py b/posthog/types.py index 5e041eb4..2b5f7600 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -160,13 +160,13 @@ def from_value_and_payload( @classmethod def from_flag_details( - cls, details: FeatureFlag | None, override_match_value: Optional[FlagValue] = None + cls, details: Union[FeatureFlag, None], override_match_value: Optional[FlagValue] = None ) -> "FeatureFlagResult | None": """ Create a FeatureFlagResult from a FeatureFlag object. Args: - details (FeatureFlag | None): The FeatureFlag object to convert. + details (Union[FeatureFlag, None]): The FeatureFlag object to convert. override_match_value (Optional[FlagValue]): If provided, this value will be used to populate the enabled and variant fields instead of the values from the FeatureFlag. From de657b70a4f2a1dc8ba5f19830e0c056bd9a2a08 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Thu, 24 Apr 2025 09:45:46 -0700 Subject: [PATCH 18/20] Fix warning --- posthog/test/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/posthog/test/test_utils.py b/posthog/test/test_utils.py index da6dba62..dfaa3090 100644 --- a/posthog/test/test_utils.py +++ b/posthog/test/test_utils.py @@ -65,7 +65,7 @@ def test_clean(self): def test_clean_with_dates(self): dict_with_dates = { "birthdate": date(1980, 1, 1), - "registration": datetime.utcnow(), + "registration": datetime.now(tz=tzutc()), } self.assertEqual(dict_with_dates, utils.clean(dict_with_dates)) From a14a4663737134fdeb35cd7fa806966d38ef45de Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Thu, 24 Apr 2025 10:03:59 -0700 Subject: [PATCH 19/20] Fix some mypy violations --- posthog/client.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index cb1e7361..e4a54e1b 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -945,7 +945,7 @@ def _get_feature_flag_result( key, distinct_id, *, - override_match_value=None, + override_match_value: Optional[FlagValue] = None, groups={}, person_properties={}, group_properties={}, @@ -972,8 +972,9 @@ def _get_feature_flag_result( flag_was_locally_evaluated = flag_value is not None if flag_was_locally_evaluated: - payload = self._compute_payload_locally(key, override_match_value or flag_value) - flag_result = FeatureFlagResult.from_value_and_payload(key, override_match_value or flag_value, payload) + lookup_match_value = override_match_value or flag_value + payload = self._compute_payload_locally(key, lookup_match_value) if lookup_match_value else None + flag_result = FeatureFlagResult.from_value_and_payload(key, lookup_match_value, payload) elif not only_evaluate_locally: try: flag_details, request_id = self._get_feature_flag_details_from_decide( @@ -1098,7 +1099,7 @@ def get_feature_flag_payload( key, distinct_id, *, - match_value=None, + match_value: Optional[FlagValue] = None, groups={}, person_properties={}, group_properties={}, @@ -1141,7 +1142,7 @@ def _capture_feature_flag_called( self, distinct_id: str, key: str, - response: FlagValue, + response: Optional[FlagValue], payload: Optional[str], flag_was_locally_evaluated: bool, groups: dict[str, str], @@ -1149,7 +1150,7 @@ def _capture_feature_flag_called( request_id: Optional[str], flag_details: Optional[FeatureFlag], ): - feature_flag_reported_key = f"{key}_{str(response)}" + feature_flag_reported_key = f"{key}_{'::null::' if response is None else str(response)}" if feature_flag_reported_key not in self.distinct_ids_feature_flags_reported[distinct_id]: properties: dict[str, Any] = { From c3755bfc74d71c61931ec66bc3de29898a7585fe Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Thu, 24 Apr 2025 10:05:05 -0700 Subject: [PATCH 20/20] Tell mypy to ignore the virtual env --- mypy.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/mypy.ini b/mypy.ini index ac78a4c6..a5dddd0f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -9,6 +9,7 @@ check_untyped_defs = True warn_unreachable = True strict_equality = True ignore_missing_imports = True +exclude = env/.*|venv/.* [mypy-django.*] ignore_missing_imports = True