Skip to content

Commit 9f24c73

Browse files
committed
Add Prime feature flag evaluation client
1 parent 1b5dd93 commit 9f24c73

3 files changed

Lines changed: 155 additions & 0 deletions

File tree

packages/prime/src/prime_cli/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
AsyncAPIClient,
2121
Config,
2222
)
23+
from prime_cli.feature_flags import (
24+
FeatureFlagsClient,
25+
evaluate_feature_flags,
26+
is_feature_enabled,
27+
)
2328

2429
__version__ = "0.5.75"
2530

@@ -34,9 +39,12 @@
3439
"CommandTimeoutError",
3540
"Config",
3641
"CreateSandboxRequest",
42+
"FeatureFlagsClient",
3743
"Sandbox",
3844
"SandboxClient",
3945
"SandboxNotRunningError",
4046
"SandboxStatus",
4147
"UpdateSandboxRequest",
48+
"evaluate_feature_flags",
49+
"is_feature_enabled",
4250
]
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from __future__ import annotations
2+
3+
from typing import TypeAlias
4+
5+
from prime_cli.core import APIClient, APIError, Config
6+
7+
JsonValue: TypeAlias = bool | int | float | str | None | list["JsonValue"] | dict[str, "JsonValue"]
8+
FeatureFlagDefaults: TypeAlias = dict[str, JsonValue]
9+
10+
11+
class FeatureFlagsClient:
12+
"""Authenticated client for Prime feature flag evaluation."""
13+
14+
def __init__(self, client: APIClient | None = None, config: Config | None = None) -> None:
15+
self.client = client or APIClient()
16+
self.config = config or self.client.config
17+
18+
def evaluate(self, defaults: FeatureFlagDefaults) -> FeatureFlagDefaults:
19+
"""Evaluate feature flags and fall back per key when the API omits a value."""
20+
if not defaults:
21+
return {}
22+
23+
from prime_cli import __version__
24+
25+
payload: dict[str, JsonValue] = {
26+
"flags": defaults,
27+
"cli_version": __version__,
28+
}
29+
if self.config.team_id:
30+
payload["team_id"] = self.config.team_id
31+
32+
response = self.client.post("/feature-flags/evaluate", json=payload)
33+
data = response.get("data")
34+
if not isinstance(data, dict):
35+
raise APIError("Feature flag response missing data")
36+
37+
flags = data.get("flags")
38+
if not isinstance(flags, dict):
39+
raise APIError("Feature flag response missing flags")
40+
41+
return {key: flags.get(key, default) for key, default in defaults.items()}
42+
43+
44+
def evaluate_feature_flags(
45+
defaults: FeatureFlagDefaults,
46+
client: APIClient | None = None,
47+
config: Config | None = None,
48+
) -> FeatureFlagDefaults:
49+
"""Evaluate Prime feature flags, returning defaults if evaluation is unavailable."""
50+
try:
51+
return FeatureFlagsClient(client=client, config=config).evaluate(defaults)
52+
except APIError:
53+
return defaults.copy()
54+
55+
56+
def is_feature_enabled(
57+
flag_key: str,
58+
default: bool = False,
59+
client: APIClient | None = None,
60+
config: Config | None = None,
61+
) -> bool:
62+
"""Evaluate a boolean Prime feature flag."""
63+
value = evaluate_feature_flags({flag_key: default}, client=client, config=config)[flag_key]
64+
return value is True
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
from prime_cli import __version__
6+
from prime_cli.core import APIClient, APIError
7+
from prime_cli.feature_flags import (
8+
FeatureFlagsClient,
9+
evaluate_feature_flags,
10+
is_feature_enabled,
11+
)
12+
13+
14+
class DummyConfig:
15+
def __init__(self, team_id: str | None = None) -> None:
16+
self.team_id = team_id
17+
18+
19+
class DummyFeatureFlagAPIClient(APIClient):
20+
def __init__(
21+
self,
22+
response: dict[str, Any] | None = None,
23+
team_id: str | None = None,
24+
error: APIError | None = None,
25+
) -> None:
26+
self.config = DummyConfig(team_id)
27+
self.response = response or {"data": {"flags": {}}}
28+
self.error = error
29+
self.posts: list[tuple[str, dict[str, Any] | None]] = []
30+
31+
def post(self, endpoint: str, json: dict[str, Any] | None = None) -> dict[str, Any]:
32+
self.posts.append((endpoint, json))
33+
if self.error:
34+
raise self.error
35+
return self.response
36+
37+
38+
def test_feature_flags_client_evaluates_flags_with_cli_context() -> None:
39+
client = DummyFeatureFlagAPIClient(
40+
response={"data": {"flags": {"cli-new-flow": True}}},
41+
team_id="team-1",
42+
)
43+
44+
result = FeatureFlagsClient(client=client).evaluate(
45+
{"cli-new-flow": False, "copy.variant": "control"}
46+
)
47+
48+
assert result == {"cli-new-flow": True, "copy.variant": "control"}
49+
assert client.posts == [
50+
(
51+
"/feature-flags/evaluate",
52+
{
53+
"flags": {"cli-new-flow": False, "copy.variant": "control"},
54+
"cli_version": __version__,
55+
"team_id": "team-1",
56+
},
57+
)
58+
]
59+
60+
61+
def test_evaluate_feature_flags_returns_defaults_when_api_unavailable() -> None:
62+
client = DummyFeatureFlagAPIClient(error=APIError("feature flag service unavailable"))
63+
defaults = {"cli-new-flow": False}
64+
65+
result = evaluate_feature_flags(defaults, client=client)
66+
67+
assert result == defaults
68+
assert result is not defaults
69+
70+
71+
def test_feature_flags_client_skips_empty_request() -> None:
72+
client = DummyFeatureFlagAPIClient()
73+
74+
assert FeatureFlagsClient(client=client).evaluate({}) == {}
75+
assert client.posts == []
76+
77+
78+
def test_is_feature_enabled_only_accepts_boolean_true() -> None:
79+
enabled_client = DummyFeatureFlagAPIClient(response={"data": {"flags": {"enabled": True}}})
80+
string_client = DummyFeatureFlagAPIClient(response={"data": {"flags": {"enabled": "true"}}})
81+
82+
assert is_feature_enabled("enabled", client=enabled_client) is True
83+
assert is_feature_enabled("enabled", default=True, client=string_client) is False

0 commit comments

Comments
 (0)