Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/prime/src/prime_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
AsyncAPIClient,
Config,
)
from prime_cli.feature_flags import (
FeatureFlagsClient,
evaluate_feature_flags,
is_feature_enabled,
)

__version__ = "0.6.8"

Expand All @@ -34,9 +39,12 @@
"CommandTimeoutError",
"Config",
"CreateSandboxRequest",
"FeatureFlagsClient",
"Sandbox",
"SandboxClient",
"SandboxNotRunningError",
"SandboxStatus",
"UpdateSandboxRequest",
"evaluate_feature_flags",
"is_feature_enabled",
]
64 changes: 64 additions & 0 deletions packages/prime/src/prime_cli/feature_flags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from __future__ import annotations

from typing import TypeAlias

from prime_cli.core import APIClient, APIError, Config

JsonValue: TypeAlias = bool | int | float | str | None | list["JsonValue"] | dict[str, "JsonValue"]
FeatureFlagDefaults: TypeAlias = dict[str, JsonValue]


class FeatureFlagsClient:
"""Authenticated client for Prime feature flag evaluation."""

def __init__(self, client: APIClient | None = None, config: Config | None = None) -> None:
self.client = client or APIClient()
self.config = config or self.client.config
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Config without client desyncs

Medium Severity

When FeatureFlagsClient or evaluate_feature_flags get a config but no client, a new APIClient uses its own Config for auth and base URL while team_id in the evaluate payload comes from the passed config, so evaluation context can disagree with the request identity.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b27f8f3. Configure here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@d42me should we fix or OK?


def evaluate(self, defaults: FeatureFlagDefaults) -> FeatureFlagDefaults:
"""Evaluate feature flags and fall back per key when the API omits a value."""
if not defaults:
return {}

from prime_cli import __version__

payload: dict[str, JsonValue] = {
"flags": defaults,
"cli_version": __version__,
}
if self.config.team_id:
payload["team_id"] = self.config.team_id

response = self.client.post("/feature-flags/evaluate", json=payload)
data = response.get("data")
if not isinstance(data, dict):
raise APIError("Feature flag response missing data")

flags = data.get("flags")
if not isinstance(flags, dict):
raise APIError("Feature flag response missing flags")

return {key: flags.get(key, default) for key, default in defaults.items()}


def evaluate_feature_flags(
defaults: FeatureFlagDefaults,
client: APIClient | None = None,
config: Config | None = None,
) -> FeatureFlagDefaults:
"""Evaluate Prime feature flags, returning defaults if evaluation is unavailable."""
try:
return FeatureFlagsClient(client=client, config=config).evaluate(defaults)
except APIError:
return defaults.copy()


def is_feature_enabled(
flag_key: str,
default: bool = False,
client: APIClient | None = None,
config: Config | None = None,
) -> bool:
"""Evaluate a boolean Prime feature flag."""
value = evaluate_feature_flags({flag_key: default}, client=client, config=config)[flag_key]
return value is True
83 changes: 83 additions & 0 deletions packages/prime/tests/test_feature_flags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
from __future__ import annotations

from typing import Any

from prime_cli import __version__
from prime_cli.core import APIClient, APIError
from prime_cli.feature_flags import (
FeatureFlagsClient,
evaluate_feature_flags,
is_feature_enabled,
)


class DummyConfig:
def __init__(self, team_id: str | None = None) -> None:
self.team_id = team_id


class DummyFeatureFlagAPIClient(APIClient):
def __init__(
self,
response: dict[str, Any] | None = None,
team_id: str | None = None,
error: APIError | None = None,
) -> None:
self.config = DummyConfig(team_id)
self.response = response or {"data": {"flags": {}}}
self.error = error
self.posts: list[tuple[str, dict[str, Any] | None]] = []

def post(self, endpoint: str, json: dict[str, Any] | None = None) -> dict[str, Any]:
self.posts.append((endpoint, json))
if self.error:
raise self.error
return self.response


def test_feature_flags_client_evaluates_flags_with_cli_context() -> None:
client = DummyFeatureFlagAPIClient(
response={"data": {"flags": {"cli-new-flow": True}}},
team_id="team-1",
)

result = FeatureFlagsClient(client=client).evaluate(
{"cli-new-flow": False, "copy.variant": "control"}
)

assert result == {"cli-new-flow": True, "copy.variant": "control"}
assert client.posts == [
(
"/feature-flags/evaluate",
{
"flags": {"cli-new-flow": False, "copy.variant": "control"},
"cli_version": __version__,
"team_id": "team-1",
},
)
]


def test_evaluate_feature_flags_returns_defaults_when_api_unavailable() -> None:
client = DummyFeatureFlagAPIClient(error=APIError("feature flag service unavailable"))
defaults = {"cli-new-flow": False}

result = evaluate_feature_flags(defaults, client=client)

assert result == defaults
assert result is not defaults


def test_feature_flags_client_skips_empty_request() -> None:
client = DummyFeatureFlagAPIClient()

assert FeatureFlagsClient(client=client).evaluate({}) == {}
assert client.posts == []


def test_is_feature_enabled_only_accepts_boolean_true() -> None:
enabled_client = DummyFeatureFlagAPIClient(response={"data": {"flags": {"enabled": True}}})
string_client = DummyFeatureFlagAPIClient(response={"data": {"flags": {"enabled": "true"}}})

assert is_feature_enabled("enabled", client=enabled_client) is True
assert is_feature_enabled("enabled", default=True, client=string_client) is False
Loading