Skip to content

Commit 224518e

Browse files
committed
Flesh out Decide response types
1 parent 2779ad1 commit 224518e

File tree

4 files changed

+198
-2
lines changed

4 files changed

+198
-2
lines changed

posthog/request.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
from datetime import date, datetime
44
from gzip import GzipFile
55
from io import BytesIO
6-
from typing import Any, Optional, Union
6+
from typing import Any, Optional, Union, cast
77

88
import requests
99
from dateutil.tz import tzutc
1010

11+
from posthog.types import DecideResponse, FeatureFlag
1112
from posthog.utils import remove_trailing_slash
1213
from posthog.version import VERSION
1314

@@ -96,6 +97,20 @@ def decide(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout
9697
res = post(api_key, host, "/decide/?v=3", gzip, timeout, **kwargs)
9798
return _process_response(res, success_message="Feature flags decided successfully")
9899

100+
def normalize_decide_response(resp: any) -> DecideResponse:
101+
if "requestId" not in resp:
102+
resp["requestId"] = None
103+
if "flags" not in resp:
104+
featureFlags = resp.get("featureFlags", {})
105+
featureFlagPayloads = resp.get("featureFlagPayloads", {})
106+
resp.pop("featureFlags", None)
107+
resp.pop("featureFlagPayloads", None)
108+
# look at each key in featureFlags and create a FeatureFlag object
109+
flags = {}
110+
for key, value in featureFlags.items():
111+
flags[key] = FeatureFlag.from_value_and_payload(key, value, featureFlagPayloads.get(key, None))
112+
resp["flags"] = flags
113+
return cast(DecideResponse, resp)
99114

100115
def remote_config(personal_api_key: str, host: Optional[str] = None, key: str = "", timeout: int = 15) -> Any:
101116
"""Get remote config flag value from remote_config API endpoint"""

posthog/test/test_feature_flag.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import pytest
2+
from posthog.types import FeatureFlag
3+
4+
5+
def test_from_value_and_payload_boolean_true_value():
6+
# Test with boolean value
7+
flag = FeatureFlag.from_value_and_payload(key="my-flag", value=True, payload='{"some": "data"}')
8+
9+
assert flag.key == "my-flag"
10+
assert flag.enabled is True
11+
assert flag.variant is None
12+
assert flag.metadata.payload == '{"some": "data"}'
13+
14+
15+
def test_from_value_and_payload_boolean_false_value():
16+
# Test with False value
17+
flag = FeatureFlag.from_value_and_payload(key="my-flag", value=False, payload=None)
18+
19+
assert flag.key == "my-flag"
20+
assert flag.enabled is False
21+
assert flag.variant is None
22+
assert flag.metadata.payload is None
23+
24+
25+
def test_from_value_and_payload_string_variant():
26+
flag = FeatureFlag.from_value_and_payload(
27+
key="my-flag", value="test-variant", payload='{"variant": "test-variant"}'
28+
)
29+
30+
assert flag.key == "my-flag"
31+
assert flag.enabled is True # String values should make enabled True
32+
assert flag.variant == "test-variant"
33+
assert flag.metadata.payload == '{"variant": "test-variant"}'

posthog/test/test_request.py

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
11
import json
22
import unittest
33
from datetime import date, datetime
4+
from unittest.mock import patch, MagicMock
5+
from parameterized import parameterized
46

57
import mock
68
import pytest
79
import requests
810

9-
from posthog.request import DatetimeSerializer, QuotaLimitError, batch_post, decide, determine_server_host
11+
from posthog.request import (
12+
DatetimeSerializer,
13+
QuotaLimitError,
14+
batch_post,
15+
decide,
16+
determine_server_host,
17+
normalize_decide_response,
18+
)
1019
from posthog.test.test_utils import TEST_API_KEY
20+
from posthog.types import FeatureFlag, FlagMetadata, FlagReason, LegacyFlagMetadata
1121

1222

1323
class TestRequests(unittest.TestCase):
@@ -75,6 +85,90 @@ def test_normal_decide_response(self):
7585
response = decide("fake_key", "fake_host")
7686
self.assertEqual(response["featureFlags"], {"flag1": True})
7787

88+
@parameterized.expand([(True,), (False,)])
89+
def test_normalize_decide_response_v4(self, has_errors: bool):
90+
resp = {
91+
"flags": {
92+
"my-flag": FeatureFlag(
93+
key="my-flag",
94+
enabled=True,
95+
variant="test-variant",
96+
reason=FlagReason(
97+
code="matched_condition", condition_index=0, description="Matched condition set 1"
98+
),
99+
metadata=FlagMetadata(id=1, payload='{"some": "json"}', version=2, description="test-description"),
100+
)
101+
},
102+
"errorsWhileComputingFlags": has_errors,
103+
"requestId": "test-id",
104+
}
105+
106+
result = normalize_decide_response(resp)
107+
108+
flag = result["flags"]["my-flag"]
109+
self.assertEqual(flag.key, "my-flag")
110+
self.assertTrue(flag.enabled)
111+
self.assertEqual(flag.variant, "test-variant")
112+
self.assertEqual(flag.get_value(), "test-variant")
113+
self.assertEqual(
114+
flag.reason, FlagReason(code="matched_condition", condition_index=0, description="Matched condition set 1")
115+
)
116+
self.assertEqual(
117+
flag.metadata, FlagMetadata(id=1, payload='{"some": "json"}', version=2, description="test-description")
118+
)
119+
self.assertEqual(result["errorsWhileComputingFlags"], has_errors)
120+
self.assertEqual(result["requestId"], "test-id")
121+
122+
def test_normalize_decide_response_legacy(self):
123+
# Test legacy response format with "featureFlags" and "featureFlagPayloads"
124+
resp = {
125+
"featureFlags": {"my-flag": "test-variant"},
126+
"featureFlagPayloads": {"my-flag": "{\"some\": \"json-payload\"}"},
127+
"errorsWhileComputingFlags": False,
128+
"requestId": "test-id",
129+
}
130+
131+
result = normalize_decide_response(resp)
132+
133+
flag = result["flags"]["my-flag"]
134+
self.assertEqual(flag.key, "my-flag")
135+
self.assertTrue(flag.enabled)
136+
self.assertEqual(flag.variant, "test-variant")
137+
self.assertEqual(flag.get_value(), "test-variant")
138+
self.assertIsNone(flag.reason)
139+
self.assertEqual(
140+
flag.metadata, LegacyFlagMetadata(payload='{"some": "json-payload"}')
141+
)
142+
self.assertFalse(result["errorsWhileComputingFlags"])
143+
self.assertEqual(result["requestId"], "test-id")
144+
# Verify legacy fields are removed
145+
self.assertNotIn("featureFlags", result)
146+
self.assertNotIn("featureFlagPayloads", result)
147+
148+
def test_normalize_decide_response_boolean_flag(self):
149+
# Test legacy response with boolean flag
150+
resp = {
151+
"featureFlags": {"my-flag": True},
152+
"errorsWhileComputingFlags": False
153+
}
154+
155+
result = normalize_decide_response(resp)
156+
157+
self.assertIn("requestId", result)
158+
self.assertIsNone(result["requestId"])
159+
160+
flag = result["flags"]["my-flag"]
161+
self.assertEqual(flag.key, "my-flag")
162+
self.assertTrue(flag.enabled)
163+
self.assertIsNone(flag.variant)
164+
self.assertIsNone(flag.reason)
165+
self.assertEqual(
166+
flag.metadata, LegacyFlagMetadata(payload=None)
167+
)
168+
self.assertFalse(result["errorsWhileComputingFlags"])
169+
self.assertNotIn("featureFlags", result)
170+
self.assertNotIn("featureFlagPayloads", result)
171+
78172

79173
@pytest.mark.parametrize(
80174
"host, expected",

posthog/types.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from dataclasses import dataclass
2+
from typing import Dict, List, TypedDict, Any
3+
4+
5+
@dataclass(frozen=True)
6+
class FlagReason(TypedDict):
7+
code: str
8+
condition_index: int
9+
description: str
10+
11+
12+
@dataclass(frozen=True)
13+
class FlagMetadata:
14+
id: int
15+
payload: Any
16+
version: int
17+
description: str
18+
19+
20+
@dataclass(frozen=True)
21+
class LegacyFlagMetadata:
22+
payload: Any
23+
24+
25+
@dataclass(frozen=True)
26+
class FeatureFlag:
27+
key: str
28+
enabled: bool
29+
variant: str | None
30+
reason: FlagReason | None
31+
metadata: FlagMetadata | LegacyFlagMetadata
32+
33+
def get_value(self):
34+
assert self.variant is None or self.enabled
35+
return self.variant or self.enabled
36+
37+
@classmethod
38+
def from_value_and_payload(cls, key: str, value: any, payload: any) -> "FeatureFlag":
39+
return cls(
40+
key=key,
41+
enabled=True if isinstance(value, str) else value,
42+
variant=value if isinstance(value, str) else None,
43+
reason=None,
44+
metadata=LegacyFlagMetadata(
45+
payload=payload,
46+
),
47+
)
48+
49+
50+
class DecideResponse(TypedDict, total=False):
51+
flags: Dict[str, FeatureFlag]
52+
errorsWhileComputingFlags: bool
53+
requestId: str
54+
quotaLimit: List[str] | None

0 commit comments

Comments
 (0)