-
Notifications
You must be signed in to change notification settings - Fork 44
Add Prime feature flag evaluation client #574
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
d42me
wants to merge
1
commit into
main
Choose a base branch
from
feature/platform-feature-flags
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
FeatureFlagsClientorevaluate_feature_flagsget aconfigbut noclient, a newAPIClientuses its ownConfigfor auth and base URL whileteam_idin the evaluate payload comes from the passedconfig, so evaluation context can disagree with the request identity.Additional Locations (1)
packages/prime/src/prime_cli/feature_flags.py#L43-L53Reviewed by Cursor Bugbot for commit b27f8f3. Configure here.
There was a problem hiding this comment.
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?