Skip to content

Commit d2f0c59

Browse files
committed
feat: OpenFeature provider for Python
1 parent 1f54ac4 commit d2f0c59

5 files changed

Lines changed: 377 additions & 1 deletion

File tree

‎Makefile‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,13 @@ setup-golang-sdk:
2525
update-golang-sdk:
2626
(cd featurevisor-go && git pull origin main)
2727

28-
.PHONY: test
28+
.PHONY: test test-openfeature
2929
test:
3030
PYTHONPATH=src python3 -m unittest discover -s tests -v
3131

32+
test-openfeature:
33+
PYTHONPATH=src python3 -m unittest discover -s tests_openfeature -v
34+
3235
.PHONY: test-example-1
3336
test-example-1:
3437
PYTHONPATH=src python3 -m unittest discover -s tests -v

‎README.md‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles.
4444
- [Registering modules](#registering-modules)
4545
- [Child instance](#child-instance)
4646
- [Close](#close)
47+
- [OpenFeature](#openfeature)
4748
- [CLI usage](#cli-usage)
4849
- [Test](#test)
4950
- [Benchmark](#benchmark)
@@ -580,6 +581,47 @@ python -m featurevisor assess-distribution \
580581
--populateUuid=deviceId
581582
```
582583

584+
## OpenFeature
585+
586+
Install Featurevisor with its optional OpenFeature dependency:
587+
588+
```bash
589+
pip install "featurevisor[openfeature]"
590+
```
591+
592+
```python
593+
from featurevisor.openfeature import FeaturevisorOpenFeatureProvider
594+
from openfeature import api
595+
from openfeature.evaluation_context import EvaluationContext
596+
597+
provider = FeaturevisorOpenFeatureProvider({"datafile": datafile_content})
598+
api.set_provider(provider)
599+
600+
client = api.get_client()
601+
enabled = client.get_boolean_value(
602+
"checkout",
603+
False,
604+
EvaluationContext(targeting_key="user-123", attributes={"country": "nl"}),
605+
)
606+
```
607+
608+
Use `checkout` for a flag, `checkout:variation` for its variation, and `checkout:title` for its `title` variable. Boolean variables use the boolean resolver. Sequences, mappings, and JSON variables use the object resolver.
609+
610+
OpenFeature's targeting key maps to `userId` by default. `targeting_key_field`, `key_separator`, and `variation_key` can customize the mapping.
611+
612+
You can also reuse an existing Featurevisor instance:
613+
614+
```python
615+
from featurevisor import create_featurevisor
616+
617+
featurevisor = create_featurevisor({"datafile": datafile_content})
618+
provider = FeaturevisorOpenFeatureProvider(featurevisor=featurevisor)
619+
```
620+
621+
The caller owns an instance passed this way. Provider shutdown does not close it. Call `featurevisor.close()` when every consumer is finished with it. When the provider creates the instance from options, the provider owns and closes it. If both are supplied, `featurevisor` takes precedence over the options dictionary.
622+
623+
See the [OpenFeature provider guide](https://featurevisor.com/docs/sdks/openfeature/) for resolution reasons, errors, metadata, tracking, lifecycle, and providers for other languages.
624+
583625
<!-- FEATUREVISOR_DOCS_END -->
584626

585627
## Development

‎pyproject.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ classifiers = [
2424
[project.scripts]
2525
featurevisor = "featurevisor.cli:main"
2626

27+
[project.optional-dependencies]
28+
openfeature = ["openfeature-sdk>=0.10.0,<0.11"]
29+
2730
[tool.setuptools]
2831
package-dir = { "" = "src" }
2932

‎src/featurevisor/openfeature.py‎

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import math
5+
from collections.abc import Mapping, Sequence
6+
from datetime import datetime
7+
from typing import Any, Callable
8+
9+
from openfeature.evaluation_context import EvaluationContext
10+
from openfeature.exception import ErrorCode
11+
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
12+
from openfeature.provider import AbstractProvider
13+
from openfeature.provider.metadata import Metadata
14+
from openfeature.track import TrackingEventDetails
15+
16+
from .instance import Featurevisor, create_featurevisor
17+
18+
19+
class FeaturevisorOpenFeatureProvider(AbstractProvider):
20+
"""OpenFeature provider backed by the Featurevisor v3 SDK."""
21+
22+
def __init__(
23+
self,
24+
options: dict[str, Any] | None = None,
25+
*,
26+
featurevisor: Featurevisor | None = None,
27+
targeting_key_field: str = "userId",
28+
key_separator: str = ":",
29+
variation_key: str = "variation",
30+
on_track: Callable[[str, EvaluationContext | None, TrackingEventDetails | None], None] | None = None,
31+
) -> None:
32+
super().__init__()
33+
self.targeting_key_field = targeting_key_field or "userId"
34+
self.key_separator = key_separator or ":"
35+
self.variation_key = variation_key or "variation"
36+
self.on_track = on_track
37+
self.datafile_error: str | None = None
38+
self._owns_featurevisor = featurevisor is None
39+
if featurevisor is not None:
40+
self.featurevisor = featurevisor
41+
else:
42+
featurevisor_options = dict(options or {})
43+
datafile = featurevisor_options.get("datafile")
44+
if isinstance(datafile, str):
45+
try:
46+
json.loads(datafile)
47+
except (TypeError, ValueError):
48+
self.datafile_error = "Could not parse datafile"
49+
original_handler = featurevisor_options.get("onDiagnostic") or featurevisor_options.get("on_diagnostic")
50+
51+
def on_diagnostic(diagnostic: dict[str, Any]) -> None:
52+
if diagnostic.get("code") == "invalid_datafile":
53+
self.datafile_error = str(diagnostic.get("message"))
54+
if diagnostic.get("code") == "datafile_set":
55+
self.datafile_error = None
56+
if original_handler:
57+
original_handler(diagnostic)
58+
59+
featurevisor_options["onDiagnostic"] = on_diagnostic
60+
self.featurevisor = create_featurevisor(featurevisor_options)
61+
self._datafile_unsubscribe = self.featurevisor.on("datafile_set", lambda _: setattr(self, "datafile_error", None))
62+
63+
def get_metadata(self) -> Metadata:
64+
return Metadata(name="Featurevisor")
65+
66+
def shutdown(self) -> None:
67+
self._datafile_unsubscribe()
68+
if self._owns_featurevisor:
69+
self.featurevisor.close()
70+
71+
def track(
72+
self,
73+
tracking_event_name: str,
74+
evaluation_context: EvaluationContext | None = None,
75+
tracking_event_details: TrackingEventDetails | None = None,
76+
) -> None:
77+
if self.on_track:
78+
self.on_track(tracking_event_name, evaluation_context, tracking_event_details)
79+
80+
def resolve_boolean_details(self, flag_key: str, default_value: bool, evaluation_context: EvaluationContext | None = None) -> FlagResolutionDetails[bool]:
81+
return self._resolve(flag_key, default_value, evaluation_context, "boolean")
82+
83+
def resolve_string_details(self, flag_key: str, default_value: str, evaluation_context: EvaluationContext | None = None) -> FlagResolutionDetails[str]:
84+
return self._resolve(flag_key, default_value, evaluation_context, "string")
85+
86+
def resolve_integer_details(self, flag_key: str, default_value: int, evaluation_context: EvaluationContext | None = None) -> FlagResolutionDetails[int]:
87+
return self._resolve(flag_key, default_value, evaluation_context, "integer")
88+
89+
def resolve_float_details(self, flag_key: str, default_value: float, evaluation_context: EvaluationContext | None = None) -> FlagResolutionDetails[float]:
90+
return self._resolve(flag_key, default_value, evaluation_context, "number")
91+
92+
def resolve_object_details(self, flag_key: str, default_value: Sequence[Any] | Mapping[str, Any], evaluation_context: EvaluationContext | None = None) -> FlagResolutionDetails[Sequence[Any] | Mapping[str, Any]]:
93+
return self._resolve(flag_key, default_value, evaluation_context, "object")
94+
95+
def _resolve(self, flag_key: str, default_value: Any, evaluation_context: EvaluationContext | None, expected_type: str) -> FlagResolutionDetails[Any]:
96+
if self.datafile_error:
97+
return self._error(default_value, ErrorCode.PARSE_ERROR, self.datafile_error)
98+
99+
feature_key, separator, selector = flag_key.partition(self.key_separator)
100+
selector = selector if separator else None
101+
context = self._context(evaluation_context)
102+
103+
if not selector:
104+
if expected_type != "boolean":
105+
return self._type_mismatch(flag_key, default_value, expected_type)
106+
evaluation = self.featurevisor.evaluate_flag(feature_key, context)
107+
value = evaluation.get("enabled")
108+
elif selector == self.variation_key:
109+
evaluation = self.featurevisor.evaluate_variation(feature_key, context)
110+
value = evaluation.get("variationValue")
111+
if value is None and evaluation.get("variation"):
112+
value = evaluation["variation"].get("value")
113+
else:
114+
evaluation = self.featurevisor.evaluate_variable(feature_key, selector, context)
115+
value = evaluation.get("variableValue")
116+
if evaluation.get("variableSchema", {}).get("type") == "json" and isinstance(value, str):
117+
try:
118+
value = json.loads(value)
119+
except (TypeError, ValueError):
120+
pass
121+
122+
metadata = self._metadata(evaluation)
123+
error_code = self._error_code(evaluation.get("reason"))
124+
if error_code:
125+
return self._error(default_value, error_code, self._error_message(evaluation), metadata)
126+
if value is None:
127+
value = default_value
128+
elif not self._matches(value, expected_type):
129+
return self._type_mismatch(flag_key, default_value, expected_type, metadata)
130+
131+
return FlagResolutionDetails(
132+
value=value,
133+
variant=self._variant(evaluation),
134+
reason=self._reason(evaluation.get("reason")),
135+
flag_metadata=metadata,
136+
)
137+
138+
def _context(self, context: EvaluationContext | None) -> dict[str, Any]:
139+
result = self._normalize(dict(context.attributes)) if context else {}
140+
if context and context.targeting_key:
141+
result[self.targeting_key_field] = context.targeting_key
142+
return result
143+
144+
def _metadata(self, evaluation: dict[str, Any]) -> dict[str, bool | int | float | str]:
145+
metadata: dict[str, bool | int | float | str] = {
146+
"featureKey": evaluation["featureKey"],
147+
"featurevisorReason": evaluation["reason"],
148+
"schemaVersion": self.featurevisor.get_schema_version(),
149+
}
150+
revision = self.featurevisor.get_revision()
151+
if revision:
152+
metadata["revision"] = revision
153+
for key in ("variableKey", "ruleKey", "bucketKey", "bucketValue", "forceIndex", "variableOverrideIndex"):
154+
if evaluation.get(key) is not None:
155+
metadata[key] = evaluation[key]
156+
return metadata
157+
158+
@staticmethod
159+
def _reason(reason: str | None) -> Reason:
160+
if reason in {"feature_not_found", "variable_not_found", "no_variations", "error"}:
161+
return Reason.ERROR
162+
if reason in {"required", "forced", "sticky", "rule", "variable_override_variation", "variable_override_rule"}:
163+
return Reason.TARGETING_MATCH
164+
if reason == "allocated":
165+
return Reason.SPLIT
166+
if reason in {"disabled", "variation_disabled", "variable_disabled"}:
167+
return Reason.DISABLED
168+
return Reason.DEFAULT
169+
170+
@staticmethod
171+
def _error_code(reason: str | None) -> ErrorCode | None:
172+
if reason in {"feature_not_found", "variable_not_found", "no_variations"}:
173+
return ErrorCode.FLAG_NOT_FOUND
174+
if reason == "error":
175+
return ErrorCode.GENERAL
176+
return None
177+
178+
@staticmethod
179+
def _error_message(evaluation: dict[str, Any]) -> str:
180+
error = evaluation.get("error")
181+
if error:
182+
return str(error)
183+
if evaluation.get("reason") == "feature_not_found":
184+
return f'Feature "{evaluation["featureKey"]}" was not found'
185+
if evaluation.get("reason") == "variable_not_found":
186+
return f'Variable "{evaluation.get("variableKey")}" was not found for feature "{evaluation["featureKey"]}"'
187+
if evaluation.get("reason") == "no_variations":
188+
return f'Feature "{evaluation["featureKey"]}" has no variations'
189+
return "Featurevisor evaluation failed"
190+
191+
@staticmethod
192+
def _variant(evaluation: dict[str, Any]) -> str | None:
193+
if evaluation.get("variationValue") is not None:
194+
return str(evaluation["variationValue"])
195+
if evaluation.get("variation"):
196+
return str(evaluation["variation"].get("value"))
197+
return None
198+
199+
@staticmethod
200+
def _matches(value: Any, expected_type: str) -> bool:
201+
if expected_type == "boolean":
202+
return isinstance(value, bool)
203+
if expected_type == "string":
204+
return isinstance(value, str)
205+
if expected_type == "integer":
206+
return isinstance(value, int) and not isinstance(value, bool)
207+
if expected_type == "number":
208+
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
209+
return isinstance(value, (dict, list, tuple))
210+
211+
@classmethod
212+
def _normalize(cls, value: Any) -> Any:
213+
if isinstance(value, datetime):
214+
return value.isoformat()
215+
if isinstance(value, Mapping):
216+
return {key: cls._normalize(item) for key, item in value.items()}
217+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
218+
return [cls._normalize(item) for item in value]
219+
return value
220+
221+
@staticmethod
222+
def _error(value: Any, code: ErrorCode, message: str, metadata: Mapping[str, Any] | None = None) -> FlagResolutionDetails[Any]:
223+
return FlagResolutionDetails(value=value, reason=Reason.ERROR, error_code=code, error_message=message, flag_metadata=metadata or {})
224+
225+
@classmethod
226+
def _type_mismatch(cls, flag_key: str, value: Any, expected_type: str, metadata: Mapping[str, Any] | None = None) -> FlagResolutionDetails[Any]:
227+
return cls._error(value, ErrorCode.TYPE_MISMATCH, f'Flag "{flag_key}" did not resolve to a {expected_type} value', metadata)

0 commit comments

Comments
 (0)