Skip to content

Commit 3e3be2e

Browse files
authored
feat(Dogfooding): Add OpenFeature SDK for server-side Flagsmith-on-Flagsmith (#7008)
1 parent 0ae31e2 commit 3e3be2e

14 files changed

Lines changed: 294 additions & 213 deletions

File tree

api/app_analytics/mappers.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
TrackFeatureEvaluationsByEnvironmentData,
2525
TrackFeatureEvaluationsByEnvironmentKwargs,
2626
)
27-
from integrations.flagsmith.client import get_client
27+
from integrations.flagsmith.client import get_openfeature_client
2828

2929

3030
def map_user_agent_to_sdk_user_agent(value: str) -> str | None:
@@ -168,10 +168,9 @@ def map_input_labels_to_labels(input_labels: InputLabels) -> Labels:
168168

169169

170170
def map_request_to_labels(request: HttpRequest) -> Labels:
171-
if not (
172-
get_client("local", local_eval=True)
173-
.get_environment_flags()
174-
.is_feature_enabled("sdk_metrics_labels")
171+
if not get_openfeature_client().get_boolean_value(
172+
"sdk_metrics_labels",
173+
default_value=False,
175174
):
176175
return {}
177176
input_labels: InputLabels = _RequestHeaderLabelsModel.model_validate(

api/conftest.py

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from unittest.mock import MagicMock
66

77
import boto3
8+
import openfeature.api as openfeature_api
89
import pytest
910
from common.environments.permissions import (
1011
MANAGE_IDENTITIES,
@@ -19,10 +20,9 @@
1920
from django.db.backends.base.creation import TEST_DATABASE_PREFIX
2021
from django.test.utils import setup_databases
2122
from flag_engine.segments.constants import EQUAL
22-
from flagsmith import Flagsmith
23-
from flagsmith.models import Flags
2423
from moto import mock_dynamodb # type: ignore[import-untyped]
2524
from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource, Table
25+
from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider
2626
from pyfakefs.fake_filesystem import FakeFilesystem
2727
from pytest import FixtureRequest
2828
from pytest_django.fixtures import SettingsWrapper
@@ -50,6 +50,7 @@
5050
from features.value_types import STRING
5151
from features.versioning.tasks import enable_v2_versioning
5252
from features.workflows.core.models import ChangeRequest
53+
from integrations.flagsmith.client import DEFAULT_OPENFEATURE_DOMAIN
5354
from integrations.github.models import GithubConfiguration, GitHubRepository
5455
from metadata.models import (
5556
Metadata,
@@ -1278,31 +1279,30 @@ def set_github_webhook_secret() -> None:
12781279
@pytest.fixture()
12791280
def enable_features(
12801281
mocker: MockerFixture,
1281-
) -> EnableFeaturesFixture:
1282+
) -> typing.Generator[EnableFeaturesFixture, None, None]:
12821283
"""
12831284
This fixture returns a callable that allows us to enable any Flagsmith feature flag(s) in tests.
12841285
1285-
Relevant issue for improving this: https://github.com/Flagsmith/flagsmith-python-client/issues/135
1286+
Uses OpenFeature's InMemoryProvider to set up enabled flags, then patches the
1287+
module-level client so that all call-sites pick up the test provider.
12861288
"""
12871289

12881290
def _enable_features(*expected_feature_names: str) -> None:
1289-
def _is_feature_enabled(feature_name: str) -> bool:
1290-
return feature_name in expected_feature_names
1291-
1292-
mock_flags = mocker.MagicMock(spec=Flags)
1293-
mock_flags.is_feature_enabled.side_effect = _is_feature_enabled
1294-
mock_flagsmith = mocker.MagicMock(spec=Flagsmith)
1295-
mock_flagsmith.get_identity_flags.return_value = mock_flags
1296-
mock_flagsmith.get_environment_flags.return_value = mock_flags
1297-
mock_clients = mocker.MagicMock(spec=dict)
1298-
mock_clients.__getitem__.return_value = mock_flagsmith
1299-
1300-
mocker.patch(
1301-
"integrations.flagsmith.client._flagsmith_clients",
1302-
new=mock_clients,
1291+
flags = {
1292+
name: InMemoryFlag(
1293+
variants={"enabled": True},
1294+
default_variant="enabled",
1295+
)
1296+
for name in expected_feature_names
1297+
}
1298+
openfeature_api.set_provider(
1299+
InMemoryProvider(flags),
1300+
domain=DEFAULT_OPENFEATURE_DOMAIN,
13031301
)
13041302

1305-
return _enable_features
1303+
yield _enable_features
1304+
1305+
openfeature_api.clear_providers()
13061306

13071307

13081308
@pytest.fixture(autouse=True)

api/environments/dynamodb/wrappers/environment_wrapper.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
flagsmith_dynamo_environment_document_compression_ratio,
2323
flagsmith_dynamo_environment_document_size_bytes,
2424
)
25-
from integrations.flagsmith.client import get_client
25+
from integrations.flagsmith.client import get_openfeature_client
2626
from util.mappers import (
2727
map_environment_to_compressed_environment_document,
2828
map_environment_to_compressed_environment_v2_document,
@@ -63,7 +63,7 @@ def _map_compressed_environment_document(
6363
) -> "CompressedEnvironmentDocument": ...
6464

6565
def _write_environments(self, environments: Iterable["Environment"]) -> None:
66-
flagsmith_client = get_client("local", local_eval=True)
66+
openfeature_client = get_openfeature_client()
6767
prefetch_related_objects(
6868
environments,
6969
"project__organisation",
@@ -74,10 +74,11 @@ def _write_environments(self, environments: Iterable["Environment"]) -> None:
7474
with self.table.batch_writer() as writer:
7575
for environment in environments:
7676
organisation = environment.project.organisation
77-
if flagsmith_client.get_identity_flags(
78-
organisation.flagsmith_identifier,
79-
traits=organisation.flagsmith_on_flagsmith_api_traits,
80-
).is_feature_enabled("compress_dynamo_documents"):
77+
if openfeature_client.get_boolean_value(
78+
"compress_dynamo_documents",
79+
default_value=False,
80+
evaluation_context=organisation.openfeature_evaluation_context,
81+
):
8182
result = self._map_compressed_environment_document(environment)
8283
writer.put_item(Item=result.document)
8384

api/environments/models.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
)
5252
from features.models import Feature, FeatureSegment, FeatureState
5353
from features.multivariate.models import MultivariateFeatureStateValue
54-
from integrations.flagsmith.client import get_client
54+
from integrations.flagsmith.client import get_openfeature_client
5555
from metadata.models import Metadata
5656
from projects.models import Project
5757
from segments.models import Segment
@@ -207,13 +207,12 @@ def enable_v2_versioning(self) -> None:
207207
# we don't want to disable it based on the flag state.
208208
return
209209

210-
flagsmith_client = get_client("local", local_eval=True)
211210
organisation = self.project.organisation
212-
enable_v2_versioning = flagsmith_client.get_identity_flags(
213-
organisation.flagsmith_identifier,
214-
traits=organisation.flagsmith_on_flagsmith_api_traits,
215-
).is_feature_enabled("enable_feature_versioning_for_new_environments")
216-
self.use_v2_feature_versioning = enable_v2_versioning
211+
self.use_v2_feature_versioning = get_openfeature_client().get_boolean_value(
212+
"enable_feature_versioning_for_new_environments",
213+
default_value=False,
214+
evaluation_context=organisation.openfeature_evaluation_context,
215+
)
217216

218217
def __str__(self): # type: ignore[no-untyped-def]
219218
return "Project %s - Environment %s" % (self.project.name, self.name)

api/features/views.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
NestedEnvironmentPermissions,
6363
)
6464
from features.value_types import BOOLEAN, INTEGER, STRING
65-
from integrations.flagsmith.client import get_client
65+
from integrations.flagsmith.client import get_openfeature_client
6666
from projects.code_references.services import (
6767
annotate_feature_queryset_with_code_references_summary,
6868
)
@@ -222,12 +222,11 @@ def get_queryset(self): # type: ignore[no-untyped-def]
222222

223223
# TODO: Delete this after https://github.com/flagsmith/flagsmith/issues/6832 is resolved
224224
organisation = project.organisation
225-
flagsmith_client = get_client("local", local_eval=True)
226-
flags = flagsmith_client.get_identity_flags(
227-
organisation.flagsmith_identifier,
228-
traits=organisation.flagsmith_on_flagsmith_api_traits,
229-
)
230-
if flags.is_feature_enabled("code_references_ui_stats"):
225+
if get_openfeature_client().get_boolean_value(
226+
"code_references_ui_stats",
227+
default_value=False,
228+
evaluation_context=organisation.openfeature_evaluation_context,
229+
):
231230
queryset = annotate_feature_queryset_with_code_references_summary(queryset)
232231
else:
233232
queryset = queryset.annotate(

api/integrations/flagsmith/client.py

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,68 @@
11
"""
2-
Wrapper module for the flagsmith client to implement singleton behaviour and provide some
3-
additional logic by wrapping the client.
2+
OpenFeature client wrapper for Flagsmith on Flagsmith feature evaluation.
43
54
Usage:
65
76
```
8-
environment_flags = get_client().get_environment_flags()
9-
identity_flags = get_client().get_identity_flags()
7+
from integrations.flagsmith.client import get_openfeature_client
8+
9+
client = get_openfeature_client()
10+
enabled = client.get_boolean_value(
11+
"flag_name", default_value=False, evaluation_context=ctx
12+
)
1013
```
1114
"""
1215

1316
import typing
1417

18+
import openfeature.api as openfeature_api
1519
from django.conf import settings
1620
from flagsmith import Flagsmith
1721
from flagsmith.offline_handlers import LocalFileHandler
22+
from openfeature.client import OpenFeatureClient
23+
from openfeature.provider import ProviderStatus
24+
from openfeature_flagsmith.provider import FlagsmithProvider
1825

1926
from integrations.flagsmith.exceptions import FlagsmithIntegrationError
2027
from integrations.flagsmith.flagsmith_service import ENVIRONMENT_JSON_PATH
2128

22-
_flagsmith_clients: dict[str, Flagsmith] = {}
29+
DEFAULT_OPENFEATURE_DOMAIN = "flagsmith-api"
2330

2431

25-
def get_client(name: str = "default", local_eval: bool = False) -> Flagsmith:
26-
global _flagsmith_clients
32+
def get_openfeature_client(
33+
domain: str = DEFAULT_OPENFEATURE_DOMAIN,
34+
) -> OpenFeatureClient:
35+
openfeature_client = openfeature_api.get_client(domain=domain)
36+
if openfeature_client.get_provider_status() != ProviderStatus.READY:
37+
initialise_provider(domain, **get_provider_kwargs())
38+
return openfeature_client
2739

28-
try:
29-
_flagsmith_client = _flagsmith_clients[name]
30-
except (KeyError, TypeError):
31-
kwargs = _get_client_kwargs()
32-
kwargs["enable_local_evaluation"] = local_eval
33-
_flagsmith_client = Flagsmith(**kwargs)
34-
_flagsmith_clients[name] = _flagsmith_client
3540

36-
return _flagsmith_client
41+
def initialise_provider(
42+
domain: str = DEFAULT_OPENFEATURE_DOMAIN,
43+
**kwargs: typing.Any,
44+
) -> None:
45+
flagsmith_client = Flagsmith(**kwargs)
46+
provider = FlagsmithProvider(client=flagsmith_client)
47+
openfeature_api.set_provider(provider, domain=domain)
3748

3849

39-
def _get_client_kwargs() -> dict[str, typing.Any]:
40-
_default_kwargs = {"offline_handler": LocalFileHandler(ENVIRONMENT_JSON_PATH)}
50+
def get_provider_kwargs() -> dict[str, typing.Any]:
51+
common_kwargs: dict[str, typing.Any] = {
52+
"offline_handler": LocalFileHandler(ENVIRONMENT_JSON_PATH),
53+
"enable_local_evaluation": True,
54+
}
4155

4256
if settings.FLAGSMITH_ON_FLAGSMITH_SERVER_OFFLINE_MODE:
43-
return {"offline_mode": True, **_default_kwargs}
57+
return {"offline_mode": True, **common_kwargs}
4458
elif (
4559
settings.FLAGSMITH_ON_FLAGSMITH_SERVER_KEY
4660
and settings.FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL
4761
):
4862
return {
4963
"environment_key": settings.FLAGSMITH_ON_FLAGSMITH_SERVER_KEY,
5064
"api_url": settings.FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL,
51-
**_default_kwargs,
65+
**common_kwargs,
5266
}
5367

5468
raise FlagsmithIntegrationError(

api/organisations/models.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
LifecycleModelMixin,
1515
hook,
1616
)
17+
from openfeature.evaluation_context import EvaluationContext
1718
from simple_history.models import HistoricalRecords # type: ignore[import-untyped]
1819

1920
from core.models import SoftDeleteExportableModel
@@ -52,7 +53,6 @@
5253
)
5354
from organisations.subscriptions.metadata import BaseSubscriptionMetadata
5455
from organisations.subscriptions.xero.metadata import XeroSubscriptionMetadata
55-
from util.engine_models.identities.traits.types import TraitValue
5656
from webhooks.models import AbstractBaseExportableWebhookModel
5757

5858
environment_cache = caches[settings.ENVIRONMENT_CACHE_NAME]
@@ -123,16 +123,15 @@ def has_enterprise_subscription(self) -> bool:
123123
return self.is_paid and self.subscription.is_enterprise
124124

125125
@property
126-
def flagsmith_identifier(self): # type: ignore[no-untyped-def]
127-
return f"org.{self.id}"
128-
129-
@property
130-
def flagsmith_on_flagsmith_api_traits(self) -> dict[str, TraitValue]:
131-
return {
132-
"organisation.id": self.id,
133-
"organisation.name": self.name,
134-
"subscription.plan": self.subscription.plan,
135-
}
126+
def openfeature_evaluation_context(self) -> EvaluationContext:
127+
return EvaluationContext(
128+
targeting_key=f"org.{self.id}",
129+
attributes={
130+
"organisation.id": self.id,
131+
"organisation.name": self.name,
132+
"subscription.plan": self.subscription.plan or "",
133+
},
134+
)
136135

137136
def over_plan_seats_limit(self, additional_seats: int = 0): # type: ignore[no-untyped-def]
138137
if self.has_paid_subscription():

api/organisations/task_helpers.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from app_analytics.analytics_db_service import get_total_events_count
1111
from app_analytics.influxdb_wrapper import get_current_api_usage
1212
from core.helpers import get_current_site_url
13-
from integrations.flagsmith.client import get_client
13+
from integrations.flagsmith.client import get_openfeature_client
1414
from organisations.models import (
1515
Organisation,
1616
OrganisationAPIUsageNotification,
@@ -128,13 +128,13 @@ def handle_api_usage_notification_for_organisation(organisation: Organisation) -
128128

129129
allowed_api_calls = subscription_cache.allowed_30d_api_calls
130130

131-
flagsmith_client = get_client("local", local_eval=True)
132-
flags = flagsmith_client.get_identity_flags(
133-
organisation.flagsmith_identifier,
134-
traits=organisation.flagsmith_on_flagsmith_api_traits,
135-
)
131+
openfeature_client = get_openfeature_client()
136132
# TODO: Default to get_total_events_count — https://github.com/Flagsmith/flagsmith/issues/6985
137-
if flags.is_feature_enabled("get_current_api_usage_deprecated"): # pragma: no cover
133+
if openfeature_client.get_boolean_value(
134+
"get_current_api_usage_deprecated",
135+
default_value=False,
136+
evaluation_context=organisation.openfeature_evaluation_context,
137+
): # pragma: no cover
138138
api_usage = get_total_events_count(organisation, period_starts_at)
139139
else:
140140
api_usage = get_current_api_usage(organisation.id, period_starts_at)

0 commit comments

Comments
 (0)