diff --git a/CHANGELOG.md b/CHANGELOG.md index f2e07bab..d73c9b0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,34 @@ +## 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). + +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 +print(result.reason) # 'matched condition set 2' (Not available for local evaluation) +``` + +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. 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 diff --git a/posthog/client.py b/posthog/client.py index 8ada4e1e..e4a54e1b 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -31,6 +31,7 @@ ) from posthog.types import ( FeatureFlag, + FeatureFlagResult, FlagMetadata, FlagsAndPayloads, FlagsResponse, @@ -939,25 +940,19 @@ def feature_enabled( return None return bool(response) - def get_feature_flag( + def _get_feature_flag_result( self, key, distinct_id, *, + override_match_value: Optional[FlagValue] = None, groups={}, person_properties={}, group_properties={}, only_evaluate_locally=False, send_feature_flag_events=True, disable_geoip=None, - ) -> Optional[FlagValue]: - """ - Get a feature flag value 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. - """ + ) -> Optional[FeatureFlagResult]: require("key", key, string_types) require("distinct_id", distinct_id, ID_TYPES) require("groups", groups, dict) @@ -969,19 +964,24 @@ def get_feature_flag( distinct_id, groups, person_properties, group_properties ) - response = self._locally_evaluate_flag(key, distinct_id, groups, person_properties, group_properties) - + flag_result = None 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: + 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: + 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( 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}") + 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}") @@ -989,8 +989,8 @@ def get_feature_flag( self._capture_feature_flag_called( distinct_id, key, - response or False, - None, + flag_result.get_value() if flag_result else None, + flag_result.payload if flag_result else None, flag_was_locally_evaluated, groups, disable_geoip, @@ -998,7 +998,67 @@ def get_feature_flag( flag_details, ) - return response + 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, + distinct_id, + *, + groups={}, + person_properties={}, + group_properties={}, + only_evaluate_locally=False, + send_feature_flag_events=True, + disable_geoip=None, + ) -> Optional[FlagValue]: + """ + Get a feature flag value 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. + """ + 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, + ) + return feature_flag_result.get_value() if feature_flag_result else None def _locally_evaluate_flag( self, @@ -1039,7 +1099,7 @@ def get_feature_flag_payload( key, distinct_id, *, - match_value=None, + match_value: Optional[FlagValue] = None, groups={}, person_properties={}, group_properties={}, @@ -1047,48 +1107,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, @@ -1112,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], @@ -1120,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] = { @@ -1131,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 new file mode 100644 index 00000000..8614e2cc --- /dev/null +++ b/posthog/test/test_feature_flag_result.py @@ -0,0 +1,402 @@ +import unittest + +import mock + +from posthog.client import Client +from posthog.test.test_utils import FAKE_TEST_API_KEY +from posthog.types import FeatureFlag, FeatureFlagResult, FlagMetadata, FlagReason + + +class TestFeatureFlagResult(unittest.TestCase): + def test_from_bool_value_and_payload(self): + 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, [1, 2, 3]) + + 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") + 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") + + self.assertEqual(result.key, "test-flag") + self.assertEqual(result.enabled, True) + self.assertEqual(result.variant, "control") + self.assertEqual(result.payload, True) + + 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 string"'), + 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 string") + + 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 string"'), + 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 string") + + 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", + 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) + + +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, + "$feature_flag_payload": 300, + }, + 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", + "$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"} + ) + 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, + "$feature_flag_payload": 300, + }, + 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": "[1, 2, 3]", + }, + }, + }, + } + + 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, [1, 2, 3]) + 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, + "$feature_flag_payload": [1, 2, 3], + }, + 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, + ) diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index ca2eaddd..d18af7ef 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,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( @@ -2438,7 +2438,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, @@ -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={}, 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)) diff --git a/posthog/types.py b/posthog/types.py index 42a9f34e..2b5f7600 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -1,3 +1,4 @@ +import json from dataclasses import dataclass from typing import Any, List, Optional, TypedDict, Union, cast @@ -102,6 +103,100 @@ class FlagsAndPayloads(TypedDict, total=True): 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. + + 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 + enabled: bool + variant: Optional[str] + payload: Optional[Any] + reason: Optional[str] + + 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) + return cls( + key=key, + enabled=enabled, + variant=variant, + payload=json.loads(payload) if isinstance(payload, str) else payload, + reason=None, + ) + + @classmethod + def from_flag_details( + cls, details: Union[FeatureFlag, None], override_match_value: Optional[FlagValue] = None + ) -> "FeatureFlagResult | None": + """ + Create a FeatureFlagResult from a FeatureFlag object. + + Args: + 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. + + Returns: + FeatureFlagResult | None: A new FeatureFlagResult instance, or None if details is None. + """ + + 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=enabled, + variant=variant, + payload=( + json.loads(details.metadata.payload) + if isinstance(details.metadata.payload, str) + else details.metadata.payload + ), + reason=details.reason.description if details.reason else None, + ) + + def normalize_flags_response(resp: Any) -> FlagsResponse: """ Normalize the response from the decide or flags API endpoint into a FlagsResponse. 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