Skip to content

Commit c5ca32a

Browse files
Merge pull request #1 from Flagsmith/init
feat(init): initial work on OpenFeature provider for Flagsmith
2 parents 6c2aa5e + 732cec2 commit c5ca32a

File tree

11 files changed

+1318
-1
lines changed

11 files changed

+1318
-1
lines changed

.github/workflows/publish.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: Publish Pypi Package
2+
3+
on:
4+
push:
5+
tags:
6+
- '*'
7+
8+
jobs:
9+
release-build:
10+
runs-on: ubuntu-latest
11+
12+
steps:
13+
- uses: actions/checkout@v5
14+
15+
- uses: actions/setup-python@v4
16+
with:
17+
python-version: "3.x"
18+
19+
- name: build release distributions
20+
run: |
21+
pip install poetry
22+
poetry build -f sdist
23+
24+
- name: upload windows dists
25+
uses: actions/upload-artifact@v4
26+
with:
27+
name: release-dists
28+
path: dist/
29+
30+
pypi-publish:
31+
runs-on: ubuntu-latest
32+
needs:
33+
- release-build
34+
permissions:
35+
id-token: write
36+
environment: release
37+
38+
steps:
39+
- name: Retrieve release distributions
40+
uses: actions/download-artifact@v4
41+
with:
42+
name: release-dists
43+
path: dist/
44+
45+
- name: Publish release distributions to PyPI
46+
uses: pypa/gh-action-pypi-publish@release/v1

.github/workflows/pytest.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: Formatting and Tests
2+
3+
on:
4+
- pull_request
5+
6+
jobs:
7+
test:
8+
runs-on: ubuntu-latest
9+
name: Pytest and formatting
10+
11+
strategy:
12+
max-parallel: 4
13+
matrix:
14+
python-version: ['3.9', '3.10', '3.11', '3.12']
15+
16+
steps:
17+
- name: Cloning repo
18+
uses: actions/checkout@v3
19+
with:
20+
fetch-depth: 0
21+
22+
- name: Set up Python ${{ matrix.python-version }}
23+
uses: actions/setup-python@v5
24+
with:
25+
python-version: ${{ matrix.python-version }}
26+
27+
- name: Install Dependencies
28+
run: |
29+
python -m pip install --upgrade pip
30+
pip install poetry
31+
poetry install
32+
33+
- name: Check Formatting
34+
run: |
35+
poetry run ruff format --check
36+
poetry run ruff check
37+
38+
- name: Run Tests
39+
run: poetry run pytest

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,4 +157,4 @@ cython_debug/
157157
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158158
# and can be added to the global gitignore or merged into this file. For a more nuclear
159159
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
160-
#.idea/
160+
.idea/

.pre-commit-config.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
repos:
2+
- repo: https://github.com/astral-sh/ruff-pre-commit
3+
rev: v0.3.2
4+
hooks:
5+
# lint
6+
- id: ruff
7+
args: [ --fix ]
8+
# format
9+
- id: ruff-format

openfeature_flagsmith/__init__.py

Whitespace-only changes.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from openfeature.exception import OpenFeatureError, ProviderFatalError
2+
3+
4+
class FlagsmithProviderError(OpenFeatureError):
5+
pass
6+
7+
8+
class FlagsmithConfigurationError(ProviderFatalError):
9+
"""
10+
This exception should be raised when the Flagsmith provider has not been set up correctly
11+
"""
12+
13+
pass

openfeature_flagsmith/provider.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import json
2+
import typing
3+
from json import JSONDecodeError
4+
5+
from flagsmith.exceptions import FlagsmithClientError
6+
from flagsmith.flagsmith import Flagsmith
7+
from openfeature.evaluation_context import EvaluationContext
8+
from openfeature.exception import (
9+
ErrorCode,
10+
FlagNotFoundError,
11+
ParseError,
12+
TypeMismatchError,
13+
)
14+
from openfeature.flag_evaluation import FlagResolutionDetails, FlagType
15+
from openfeature.provider import Metadata
16+
from openfeature.provider.provider import AbstractProvider
17+
18+
from openfeature_flagsmith.exceptions import FlagsmithProviderError
19+
20+
_BASIC_FLAG_TYPE_MAPPINGS = {
21+
FlagType.BOOLEAN: bool,
22+
FlagType.INTEGER: int,
23+
FlagType.FLOAT: float,
24+
FlagType.STRING: str,
25+
}
26+
27+
28+
class FlagsmithProvider(AbstractProvider):
29+
def __init__(
30+
self,
31+
client: Flagsmith,
32+
use_boolean_config_value: bool = False,
33+
return_value_for_disabled_flags: bool = False,
34+
use_flagsmith_defaults: bool = False,
35+
):
36+
self._client = client
37+
self.return_value_for_disabled_flags = return_value_for_disabled_flags
38+
self.use_flagsmith_defaults = use_flagsmith_defaults
39+
self.use_boolean_config_value = use_boolean_config_value
40+
41+
def get_metadata(self) -> Metadata:
42+
return Metadata(name="FlagsmithProvider")
43+
44+
def resolve_boolean_details(
45+
self,
46+
key: str,
47+
default_value: bool,
48+
evaluation_context: EvaluationContext = EvaluationContext(),
49+
) -> FlagResolutionDetails[bool]:
50+
return self._resolve(key, FlagType.BOOLEAN, default_value, evaluation_context)
51+
52+
def resolve_string_details(
53+
self,
54+
key: str,
55+
default_value: str,
56+
evaluation_context: EvaluationContext = EvaluationContext(),
57+
) -> FlagResolutionDetails[str]:
58+
return self._resolve(key, FlagType.STRING, default_value, evaluation_context)
59+
60+
def resolve_integer_details(
61+
self,
62+
key: str,
63+
default_value: int,
64+
evaluation_context: EvaluationContext = EvaluationContext(),
65+
) -> FlagResolutionDetails[int]:
66+
return self._resolve(key, FlagType.INTEGER, default_value, evaluation_context)
67+
68+
def resolve_float_details(
69+
self,
70+
key: str,
71+
default_value: float,
72+
evaluation_context: EvaluationContext = EvaluationContext(),
73+
) -> FlagResolutionDetails[float]:
74+
return self._resolve(key, FlagType.FLOAT, default_value, evaluation_context)
75+
76+
def resolve_object_details(
77+
self,
78+
key: str,
79+
default_value: typing.Union[dict, list],
80+
evaluation_context: EvaluationContext = EvaluationContext(),
81+
) -> FlagResolutionDetails[typing.Union[dict, list]]:
82+
return self._resolve(key, FlagType.OBJECT, default_value, evaluation_context)
83+
84+
def _resolve(
85+
self,
86+
key: str,
87+
flag_type: FlagType,
88+
default_value: typing.Any,
89+
evaluation_context: EvaluationContext,
90+
) -> FlagResolutionDetails:
91+
try:
92+
flag = self._get_flags(evaluation_context).get_flag(key)
93+
except FlagsmithClientError as e:
94+
raise FlagsmithProviderError(
95+
error_code=ErrorCode.GENERAL,
96+
error_message="An error occurred retrieving flags from Flagsmith client.",
97+
) from e
98+
99+
if flag.is_default and not self.use_flagsmith_defaults:
100+
raise FlagNotFoundError(error_message="Flag '%s' was not found." % key)
101+
102+
if flag_type == FlagType.BOOLEAN and not self.use_boolean_config_value:
103+
return FlagResolutionDetails(value=flag.enabled)
104+
105+
if not (self.return_value_for_disabled_flags or flag.enabled):
106+
raise FlagsmithProviderError(
107+
error_code=ErrorCode.GENERAL,
108+
error_message="Flag '%s' is not enabled." % key,
109+
)
110+
111+
required_type = _BASIC_FLAG_TYPE_MAPPINGS.get(flag_type)
112+
if required_type and isinstance(flag.value, required_type):
113+
return FlagResolutionDetails(value=flag.value)
114+
elif flag_type is FlagType.OBJECT and isinstance(flag.value, str):
115+
try:
116+
return FlagResolutionDetails(value=json.loads(flag.value))
117+
except JSONDecodeError as e:
118+
msg = "Unable to parse object from value for flag '%s'" % key
119+
raise ParseError(error_message=msg) from e
120+
121+
raise TypeMismatchError(
122+
error_message="Value for flag '%s' is not of type '%s'"
123+
% (key, flag_type.value)
124+
)
125+
126+
def _get_flags(self, evaluation_context: EvaluationContext = EvaluationContext()):
127+
if targeting_key := evaluation_context.targeting_key:
128+
return self._client.get_identity_flags(
129+
identifier=targeting_key,
130+
traits=evaluation_context.attributes.get("traits", {}),
131+
)
132+
return self._client.get_environment_flags()

0 commit comments

Comments
 (0)