Skip to content

Commit a27e98a

Browse files
committed
feat: Add initial implementation of provider
1 parent 65524c7 commit a27e98a

File tree

11 files changed

+691
-1
lines changed

11 files changed

+691
-1
lines changed

Makefile

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
.PHONY: help
2+
help: #! Show this help message
3+
@echo 'Usage: make [target] ... '
4+
@echo ''
5+
@echo 'Targets:'
6+
@grep -h -F '#!' $(MAKEFILE_LIST) | grep -v grep | sed 's/:.*#!/:/' | column -t -s":"
7+
8+
.PHONY: install
9+
install:
10+
@poetry install
11+
12+
#
13+
# Quality control checks
14+
#
15+
16+
.PHONY: test
17+
test: #! Run unit tests
18+
test: install
19+
@poetry run pytest $(PYTEST_FLAGS)
20+
21+
.PHONY: lint
22+
lint: #! Run type analysis and linting checks
23+
lint: install
24+
@poetry run mypy ld_openfeature tests

client/__init__.py

Whitespace-only changes.

ld_openfeature/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from ld_openfeature.provider import LaunchDarklyProvider
2+
3+
__all__ = [
4+
'LaunchDarklyProvider'
5+
]
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
from logging import getLogger
2+
from typing import Any, Dict, List, Optional
3+
4+
from ldclient.context import Context, ContextBuilder, ContextMultiBuilder
5+
from openfeature.provider.provider import EvaluationContext
6+
7+
8+
logger = getLogger("launchdarkly-openfeature-server")
9+
10+
11+
class EvaluationContextConverter:
12+
def to_ld_context(self, context: EvaluationContext) -> Context:
13+
"""
14+
Create an Context from an EvaluationContext.
15+
16+
A context will always be created, but the created context may be
17+
invalid. Log messages will be written to indicate the source of the
18+
problem.
19+
"""
20+
attributes = context.attributes
21+
22+
kind = attributes.get('kind')
23+
if kind == "multi":
24+
return self.__build_multi_context(context)
25+
26+
if kind is not None and not isinstance(kind, str):
27+
logger.warning("'kind' was set to a non-string value; defaulting to user")
28+
kind = 'user'
29+
30+
targeting_key = context.targeting_key
31+
key = attributes.get('key')
32+
targeting_key = self.__get_targeting_key(targeting_key, key)
33+
34+
kind = "user" if kind is None else kind
35+
return self.__build_single_context(attributes, kind, targeting_key)
36+
37+
def __get_targeting_key(self, targeting_key: Optional[str], key: Any) -> str:
38+
# The targeting key may be set but empty. So we want to treat an empty
39+
# string as a not defined one. Later it could become null, so we will
40+
# need to check that.
41+
if targeting_key is not None and targeting_key != "" and isinstance(key, str):
42+
# There is both a targeting key and a key. It will work, but
43+
# probably is not intentional.
44+
logger.warning("EvaluationContext contained both a 'key' and 'targetingKey'.")
45+
46+
if key is not None and not isinstance(key, str):
47+
logger.warning("A non-string 'key' attribute was provided.")
48+
49+
if key is not None and isinstance(key, str):
50+
targeting_key = targeting_key if targeting_key else key
51+
52+
if targeting_key is None or targeting_key == "":
53+
logger.error("The EvaluationContext must contain either a 'targetingKey' or a 'key' and the type must be a string.")
54+
55+
return targeting_key if targeting_key else ""
56+
57+
def __build_multi_context(self, context: EvaluationContext) -> Context:
58+
builder = ContextMultiBuilder()
59+
60+
for kind, attributes in context.attributes.items():
61+
if kind == 'kind':
62+
continue
63+
64+
if not isinstance(attributes, Dict):
65+
logger.warning("Top level attributes in a multi-kind context should be dictionaries")
66+
continue
67+
68+
key = attributes.get('key')
69+
targeting_key = attributes.get('targeting_key')
70+
71+
if targeting_key is not None and not isinstance(targeting_key, str):
72+
continue
73+
74+
targeting_key = self.__get_targeting_key(targeting_key, key)
75+
single_context = self.__build_single_context(attributes, kind, targeting_key)
76+
77+
builder.add(single_context)
78+
79+
return builder.build()
80+
81+
def __build_single_context(self, attributes: Dict, kind: str, key: str) -> Context:
82+
builder = ContextBuilder(key)
83+
builder.kind(kind)
84+
85+
for k, v in attributes.items():
86+
# TODO: In PHP this was camel case. Is that the case for python? Check the docs.
87+
if k == 'key' or k == 'targeting_key':
88+
continue
89+
90+
if k == 'name' and isinstance(v, str):
91+
builder.name(v)
92+
elif k == 'name':
93+
logger.error("The attribute 'name' must be a string")
94+
elif k == 'anonymous' and isinstance(v, bool):
95+
builder.anonymous(v)
96+
elif k == 'anonymous':
97+
logger.error("The attribute 'anonymous' must be a boolean")
98+
elif k == 'privateAttributes' and isinstance(v, list):
99+
private_attributes: List[str] = []
100+
for private_attribute in v:
101+
if not isinstance(private_attribute, str):
102+
logger.error("'privateAttributes' must be an array of only string values")
103+
continue
104+
105+
private_attributes.append(private_attribute)
106+
107+
if private_attributes:
108+
builder.private(*private_attributes)
109+
elif k == 'privateAttributes':
110+
logger.error("The attribute 'privateAttributes' must be an array")
111+
else:
112+
builder.set(k, v)
113+
114+
return builder.build()
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from typing import Optional
2+
3+
from ldclient.evaluation import EvaluationDetail
4+
from openfeature.exception import ErrorCode
5+
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
6+
7+
8+
class ResolutionDetailsConverter:
9+
def to_resolution_details(self, result: EvaluationDetail) -> FlagResolutionDetails:
10+
value = result.value
11+
is_default = result.is_default_value()
12+
variation_index = result.variation_index
13+
14+
reason = result.reason
15+
reason_kind = reason.get('kind')
16+
reason_kind = reason_kind if isinstance(reason_kind, str) else ''
17+
18+
openfeature_reason = self.__kind_to_reason(reason_kind)
19+
20+
openfeature_error_code: Optional[ErrorCode] = None
21+
if reason_kind == "ERROR":
22+
openfeature_error_code = self.__error_kind_to_code(reason.get('errorKind'))
23+
24+
openfeature_variant: Optional[str] = None
25+
if not is_default:
26+
openfeature_variant = str(variation_index)
27+
28+
return FlagResolutionDetails(
29+
value=value,
30+
error_code=openfeature_error_code,
31+
error_message=None,
32+
reason=openfeature_reason,
33+
variant=openfeature_variant
34+
# flag_metadata = FlagMetadata = field(default_factory=dict)
35+
)
36+
pass
37+
38+
@staticmethod
39+
def __kind_to_reason(kind: str) -> str:
40+
if kind == 'OFF':
41+
return Reason.DISABLED
42+
elif kind == 'TARGET_MATCH':
43+
return Reason.TARGETING_MATCH
44+
elif kind == 'ERROR':
45+
return Reason.ERROR
46+
47+
# NOTE: FALLTHROUGH, RULE_MATCH, PREREQUISITE_FAILED intentionally
48+
# omitted
49+
50+
return kind
51+
52+
@staticmethod
53+
def __error_kind_to_code(error_kind: Optional[str]) -> ErrorCode:
54+
if error_kind is None:
55+
return ErrorCode.GENERAL
56+
57+
if error_kind == 'CLIENT_NOT_READY':
58+
return ErrorCode.PROVIDER_NOT_READY
59+
elif error_kind == 'FLAG_NOT_FOUND':
60+
return ErrorCode.FLAG_NOT_FOUND
61+
elif error_kind == 'MALFORMED_FLAG':
62+
return ErrorCode.PARSE_ERROR
63+
elif error_kind == 'USER_NOT_SPECIFIED':
64+
return ErrorCode.TARGETING_KEY_MISSING
65+
66+
# NOTE: EXCEPTION_ERROR intentionally omitted
67+
68+
return ErrorCode.GENERAL

ld_openfeature/provider.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
from typing import Any, List, Optional, Union
2+
3+
from ldclient import LDClient
4+
from openfeature.evaluation_context import EvaluationContext
5+
from openfeature.exception import ErrorCode
6+
from openfeature.flag_evaluation import FlagResolutionDetails, FlagType, Reason
7+
from openfeature.hook import Hook
8+
from openfeature.provider.metadata import Metadata
9+
from openfeature.provider.provider import AbstractProvider
10+
11+
from ld_openfeature.impl.context_converter import EvaluationContextConverter
12+
from ld_openfeature.impl.details_converter import ResolutionDetailsConverter
13+
14+
15+
class LaunchDarklyProvider(AbstractProvider):
16+
def __init__(self, client: LDClient):
17+
self.__client = client
18+
19+
self.__context_converter = EvaluationContextConverter()
20+
self.__details_converter = ResolutionDetailsConverter()
21+
22+
def get_metadata(self) -> Metadata:
23+
return Metadata("launchdarkly-openfeature-server")
24+
25+
def get_provider_hooks(self) -> List[Hook]:
26+
return []
27+
28+
def resolve_boolean_details(
29+
self,
30+
flag_key: str,
31+
default_value: bool,
32+
evaluation_context: Optional[EvaluationContext] = None,
33+
) -> FlagResolutionDetails[bool]:
34+
"""Resolves the flag value for the provided flag key as a boolean"""
35+
return self.__resolve_value(FlagType(FlagType.BOOLEAN), flag_key, default_value, evaluation_context)
36+
37+
def resolve_string_details(
38+
self,
39+
flag_key: str,
40+
default_value: str,
41+
evaluation_context: Optional[EvaluationContext] = None,
42+
) -> FlagResolutionDetails[str]:
43+
"""Resolves the flag value for the provided flag key as a string"""
44+
return self.__resolve_value(FlagType(FlagType.STRING), flag_key, default_value, evaluation_context)
45+
46+
def resolve_integer_details(
47+
self,
48+
flag_key: str,
49+
default_value: int,
50+
evaluation_context: Optional[EvaluationContext] = None,
51+
) -> FlagResolutionDetails[int]:
52+
"""Resolves the flag value for the provided flag key as a integer"""
53+
return self.__resolve_value(FlagType(FlagType.INTEGER), flag_key, default_value, evaluation_context)
54+
55+
def resolve_float_details(
56+
self,
57+
flag_key: str,
58+
default_value: float,
59+
evaluation_context: Optional[EvaluationContext] = None,
60+
) -> FlagResolutionDetails[float]:
61+
"""Resolves the flag value for the provided flag key as a float"""
62+
return self.__resolve_value(FlagType(FlagType.FLOAT), flag_key, default_value, evaluation_context)
63+
64+
def resolve_object_details(
65+
self,
66+
flag_key: str,
67+
default_value: Union[dict, list],
68+
evaluation_context: Optional[EvaluationContext] = None,
69+
) -> FlagResolutionDetails[Union[dict, list]]:
70+
"""Resolves the flag value for the provided flag key as a list or dictionary"""
71+
return self.__resolve_value(FlagType(FlagType.OBJECT), flag_key, default_value, evaluation_context)
72+
73+
def __resolve_value(self, flag_type: FlagType, flag_key: str, default_value: Any, evaluation_context: Optional[EvaluationContext] = None) -> FlagResolutionDetails:
74+
if evaluation_context is None:
75+
return FlagResolutionDetails(
76+
value=default_value,
77+
reason=Reason(Reason.ERROR),
78+
error_code=ErrorCode.TARGETING_KEY_MISSING
79+
)
80+
81+
ld_context = self.__context_converter.to_ld_context(evaluation_context)
82+
result = self.__client.variation_detail(flag_key, ld_context, default_value)
83+
84+
if flag_type == FlagType.BOOLEAN and not isinstance(result.value, bool):
85+
return self.__mismatched_type_details(default_value)
86+
elif flag_type == FlagType.STRING and not isinstance(result.value, str):
87+
return self.__mismatched_type_details(default_value)
88+
elif flag_type == FlagType.INTEGER and isinstance(result.value, bool):
89+
# Python treats boolean values as instances of int
90+
return self.__mismatched_type_details(default_value)
91+
elif flag_type == FlagType.FLOAT and isinstance(result.value, bool):
92+
# Python treats boolean values as instances of int
93+
return self.__mismatched_type_details(default_value)
94+
elif flag_type == FlagType.INTEGER and not isinstance(result.value, int):
95+
return self.__mismatched_type_details(default_value)
96+
elif flag_type == FlagType.FLOAT and not isinstance(result.value, float) and not isinstance(result.value, int):
97+
return self.__mismatched_type_details(default_value)
98+
elif flag_type == FlagType.OBJECT and not isinstance(result.value, dict) and not isinstance(result.value, list):
99+
return self.__mismatched_type_details(default_value)
100+
101+
return self.__details_converter.to_resolution_details(result)
102+
103+
def __mismatched_type_details(self, default_value: Any) -> FlagResolutionDetails:
104+
return FlagResolutionDetails(
105+
value=default_value,
106+
reason=Reason(Reason.ERROR),
107+
error_code=ErrorCode.TYPE_MISMATCH
108+
)

ld_openfeature/py.typed

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

pyproject.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ classifiers = [
2121
"Topic :: Software Development :: Libraries",
2222
]
2323
packages = [
24-
{ include = "client" },
24+
{ include = "ld_openfeature" },
2525
{ include = "tests" },
2626
]
2727

@@ -37,6 +37,7 @@ pytest = ">=2.8"
3737
pytest-cov = ">=2.4.0"
3838
pytest-mypy = "==0.10.3"
3939
mypy = "==1.8.0"
40+
isort = "^5.13.2"
4041

4142

4243
[tool.mypy]
@@ -45,6 +46,10 @@ install_types = true
4546
non_interactive = true
4647

4748

49+
[tool.isort]
50+
py_version=38
51+
52+
4853
[tool.pytest.ini_options]
4954
addopts = ["-ra"]
5055

0 commit comments

Comments
 (0)