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
9 changes: 4 additions & 5 deletions api/app_analytics/mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
TrackFeatureEvaluationsByEnvironmentData,
TrackFeatureEvaluationsByEnvironmentKwargs,
)
from integrations.flagsmith.client import get_client
from integrations.flagsmith.client import get_openfeature_client


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


def map_request_to_labels(request: HttpRequest) -> Labels:
if not (
get_client("local", local_eval=True)
.get_environment_flags()
.is_feature_enabled("sdk_metrics_labels")
if not get_openfeature_client().get_boolean_value(
"sdk_metrics_labels",
default_value=False,
):
return {}
input_labels: InputLabels = _RequestHeaderLabelsModel.model_validate(
Expand Down
38 changes: 19 additions & 19 deletions api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from unittest.mock import MagicMock

import boto3
import openfeature.api as openfeature_api
import pytest
from common.environments.permissions import (
MANAGE_IDENTITIES,
Expand All @@ -19,10 +20,9 @@
from django.db.backends.base.creation import TEST_DATABASE_PREFIX
from django.test.utils import setup_databases
from flag_engine.segments.constants import EQUAL
from flagsmith import Flagsmith
from flagsmith.models import Flags
from moto import mock_dynamodb # type: ignore[import-untyped]
from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource, Table
from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider
from pyfakefs.fake_filesystem import FakeFilesystem
from pytest import FixtureRequest
from pytest_django.fixtures import SettingsWrapper
Expand Down Expand Up @@ -50,6 +50,7 @@
from features.value_types import STRING
from features.versioning.tasks import enable_v2_versioning
from features.workflows.core.models import ChangeRequest
from integrations.flagsmith.client import DEFAULT_OPENFEATURE_DOMAIN
from integrations.github.models import GithubConfiguration, GitHubRepository
from metadata.models import (
Metadata,
Expand Down Expand Up @@ -1278,31 +1279,30 @@ def set_github_webhook_secret() -> None:
@pytest.fixture()
def enable_features(
mocker: MockerFixture,
) -> EnableFeaturesFixture:
) -> typing.Generator[EnableFeaturesFixture, None, None]:
"""
This fixture returns a callable that allows us to enable any Flagsmith feature flag(s) in tests.

Relevant issue for improving this: https://github.com/Flagsmith/flagsmith-python-client/issues/135
Uses OpenFeature's InMemoryProvider to set up enabled flags, then patches the
module-level client so that all call-sites pick up the test provider.
"""

def _enable_features(*expected_feature_names: str) -> None:
def _is_feature_enabled(feature_name: str) -> bool:
return feature_name in expected_feature_names

mock_flags = mocker.MagicMock(spec=Flags)
mock_flags.is_feature_enabled.side_effect = _is_feature_enabled
mock_flagsmith = mocker.MagicMock(spec=Flagsmith)
mock_flagsmith.get_identity_flags.return_value = mock_flags
mock_flagsmith.get_environment_flags.return_value = mock_flags
mock_clients = mocker.MagicMock(spec=dict)
mock_clients.__getitem__.return_value = mock_flagsmith

mocker.patch(
"integrations.flagsmith.client._flagsmith_clients",
new=mock_clients,
flags = {
name: InMemoryFlag(
variants={"enabled": True},
default_variant="enabled",
Comment on lines +1293 to +1294
Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, this is how to create an enabled by default in-memory flag for OF. No, there is no shorter way to do this.

)
for name in expected_feature_names
}
openfeature_api.set_provider(
InMemoryProvider(flags),
domain=DEFAULT_OPENFEATURE_DOMAIN,
)

return _enable_features
yield _enable_features

openfeature_api.clear_providers()


@pytest.fixture(autouse=True)
Expand Down
13 changes: 7 additions & 6 deletions api/environments/dynamodb/wrappers/environment_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
flagsmith_dynamo_environment_document_compression_ratio,
flagsmith_dynamo_environment_document_size_bytes,
)
from integrations.flagsmith.client import get_client
from integrations.flagsmith.client import get_openfeature_client
from util.mappers import (
map_environment_to_compressed_environment_document,
map_environment_to_compressed_environment_v2_document,
Expand Down Expand Up @@ -63,7 +63,7 @@ def _map_compressed_environment_document(
) -> "CompressedEnvironmentDocument": ...

def _write_environments(self, environments: Iterable["Environment"]) -> None:
flagsmith_client = get_client("local", local_eval=True)
openfeature_client = get_openfeature_client()
prefetch_related_objects(
environments,
"project__organisation",
Expand All @@ -74,10 +74,11 @@ def _write_environments(self, environments: Iterable["Environment"]) -> None:
with self.table.batch_writer() as writer:
for environment in environments:
organisation = environment.project.organisation
if flagsmith_client.get_identity_flags(
organisation.flagsmith_identifier,
traits=organisation.flagsmith_on_flagsmith_api_traits,
).is_feature_enabled("compress_dynamo_documents"):
if openfeature_client.get_boolean_value(
"compress_dynamo_documents",
default_value=False,
evaluation_context=organisation.openfeature_evaluation_context,
):
result = self._map_compressed_environment_document(environment)
writer.put_item(Item=result.document)

Expand Down
13 changes: 6 additions & 7 deletions api/environments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
)
from features.models import Feature, FeatureSegment, FeatureState
from features.multivariate.models import MultivariateFeatureStateValue
from integrations.flagsmith.client import get_client
from integrations.flagsmith.client import get_openfeature_client
from metadata.models import Metadata
from projects.models import Project
from segments.models import Segment
Expand Down Expand Up @@ -207,13 +207,12 @@ def enable_v2_versioning(self) -> None:
# we don't want to disable it based on the flag state.
return

flagsmith_client = get_client("local", local_eval=True)
organisation = self.project.organisation
enable_v2_versioning = flagsmith_client.get_identity_flags(
organisation.flagsmith_identifier,
traits=organisation.flagsmith_on_flagsmith_api_traits,
).is_feature_enabled("enable_feature_versioning_for_new_environments")
self.use_v2_feature_versioning = enable_v2_versioning
self.use_v2_feature_versioning = get_openfeature_client().get_boolean_value(
"enable_feature_versioning_for_new_environments",
default_value=False,
evaluation_context=organisation.openfeature_evaluation_context,
)

def __str__(self): # type: ignore[no-untyped-def]
return "Project %s - Environment %s" % (self.project.name, self.name)
Expand Down
13 changes: 6 additions & 7 deletions api/features/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
NestedEnvironmentPermissions,
)
from features.value_types import BOOLEAN, INTEGER, STRING
from integrations.flagsmith.client import get_client
from integrations.flagsmith.client import get_openfeature_client
from projects.code_references.services import (
annotate_feature_queryset_with_code_references_summary,
)
Expand Down Expand Up @@ -222,12 +222,11 @@ def get_queryset(self): # type: ignore[no-untyped-def]

# TODO: Delete this after https://github.com/flagsmith/flagsmith/issues/6832 is resolved
organisation = project.organisation
flagsmith_client = get_client("local", local_eval=True)
flags = flagsmith_client.get_identity_flags(
organisation.flagsmith_identifier,
traits=organisation.flagsmith_on_flagsmith_api_traits,
)
if flags.is_feature_enabled("code_references_ui_stats"):
if get_openfeature_client().get_boolean_value(
"code_references_ui_stats",
default_value=False,
evaluation_context=organisation.openfeature_evaluation_context,
):
queryset = annotate_feature_queryset_with_code_references_summary(queryset)
else:
queryset = queryset.annotate(
Expand Down
53 changes: 34 additions & 19 deletions api/integrations/flagsmith/client.py
Original file line number Diff line number Diff line change
@@ -1,54 +1,69 @@
"""
Wrapper module for the flagsmith client to implement singleton behaviour and provide some
additional logic by wrapping the client.
OpenFeature client wrapper for Flagsmith on Flagsmith feature evaluation.

Usage:

```
environment_flags = get_client().get_environment_flags()
identity_flags = get_client().get_identity_flags()
from integrations.flagsmith.client import get_openfeature_client

client = get_openfeature_client()
enabled = client.get_boolean_value(
"flag_name", default_value=False, evaluation_context=ctx
)
```
"""

import typing

import openfeature.api as openfeature_api
from django.conf import settings
from flagsmith import Flagsmith
from flagsmith.offline_handlers import LocalFileHandler
from openfeature.client import OpenFeatureClient
from openfeature.provider import ProviderStatus
from openfeature_flagsmith.provider import FlagsmithProvider

from integrations.flagsmith.exceptions import FlagsmithIntegrationError
from integrations.flagsmith.flagsmith_service import ENVIRONMENT_JSON_PATH

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


def get_client(name: str = "default", local_eval: bool = False) -> Flagsmith:
global _flagsmith_clients
def get_openfeature_client(
domain: str = DEFAULT_OPENFEATURE_DOMAIN,
**flagsmith_kwargs: typing.Any,
) -> OpenFeatureClient:
openfeature_client = openfeature_api.get_client(domain=domain)
if openfeature_client.get_provider_status() != ProviderStatus.READY:
initialise_provider(domain, **(flagsmith_kwargs or get_provider_kwargs()))
return openfeature_client

try:
_flagsmith_client = _flagsmith_clients[name]
except (KeyError, TypeError):
kwargs = _get_client_kwargs()
kwargs["enable_local_evaluation"] = local_eval
_flagsmith_client = Flagsmith(**kwargs)
_flagsmith_clients[name] = _flagsmith_client

return _flagsmith_client
def initialise_provider(
domain: str = DEFAULT_OPENFEATURE_DOMAIN,
**kwargs: typing.Any,
) -> None:
flagsmith_client = Flagsmith(**kwargs)
provider = FlagsmithProvider(client=flagsmith_client)
openfeature_api.set_provider(provider, domain=domain)


def _get_client_kwargs() -> dict[str, typing.Any]:
_default_kwargs = {"offline_handler": LocalFileHandler(ENVIRONMENT_JSON_PATH)}
def get_provider_kwargs() -> dict[str, typing.Any]:
common_kwargs: dict[str, typing.Any] = {
"offline_handler": LocalFileHandler(ENVIRONMENT_JSON_PATH),
"enable_local_evaluation": True,
}

if settings.FLAGSMITH_ON_FLAGSMITH_SERVER_OFFLINE_MODE:
return {"offline_mode": True, **_default_kwargs}
return {"offline_mode": True, **common_kwargs}
elif (
settings.FLAGSMITH_ON_FLAGSMITH_SERVER_KEY
and settings.FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL
):
return {
"environment_key": settings.FLAGSMITH_ON_FLAGSMITH_SERVER_KEY,
"api_url": settings.FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL,
**_default_kwargs,
**common_kwargs,
}

raise FlagsmithIntegrationError(
Expand Down
21 changes: 10 additions & 11 deletions api/organisations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
LifecycleModelMixin,
hook,
)
from openfeature.evaluation_context import EvaluationContext
from simple_history.models import HistoricalRecords # type: ignore[import-untyped]

from core.models import SoftDeleteExportableModel
Expand Down Expand Up @@ -52,7 +53,6 @@
)
from organisations.subscriptions.metadata import BaseSubscriptionMetadata
from organisations.subscriptions.xero.metadata import XeroSubscriptionMetadata
from util.engine_models.identities.traits.types import TraitValue
from webhooks.models import AbstractBaseExportableWebhookModel

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

@property
def flagsmith_identifier(self): # type: ignore[no-untyped-def]
return f"org.{self.id}"

@property
def flagsmith_on_flagsmith_api_traits(self) -> dict[str, TraitValue]:
return {
"organisation.id": self.id,
"organisation.name": self.name,
"subscription.plan": self.subscription.plan,
}
def openfeature_evaluation_context(self) -> EvaluationContext:
return EvaluationContext(
targeting_key=f"org.{self.id}",
attributes={
"organisation.id": self.id,
"organisation.name": self.name,
"subscription.plan": self.subscription.plan or "",
},
)

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