From 6db9e932cc96bc6892884c9e194f15c94f5e154b Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Tue, 14 Jul 2026 21:36:46 +0200 Subject: [PATCH] feat: OpenFeature provider for Python --- Makefile | 5 +- README.md | 42 ++++++ pyproject.toml | 3 + src/featurevisor/openfeature.py | 227 +++++++++++++++++++++++++++++ tests_openfeature/test_provider.py | 101 +++++++++++++ 5 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 src/featurevisor/openfeature.py create mode 100644 tests_openfeature/test_provider.py diff --git a/Makefile b/Makefile index 4ca2334..a9c876c 100644 --- a/Makefile +++ b/Makefile @@ -25,10 +25,13 @@ setup-golang-sdk: update-golang-sdk: (cd featurevisor-go && git pull origin main) -.PHONY: test +.PHONY: test test-openfeature test: PYTHONPATH=src python3 -m unittest discover -s tests -v +test-openfeature: + PYTHONPATH=src python3 -m unittest discover -s tests_openfeature -v + .PHONY: test-example-1 test-example-1: PYTHONPATH=src python3 -m unittest discover -s tests -v diff --git a/README.md b/README.md index bc1e151..43c1c71 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles. - [Registering modules](#registering-modules) - [Child instance](#child-instance) - [Close](#close) +- [OpenFeature](#openfeature) - [CLI usage](#cli-usage) - [Test](#test) - [Benchmark](#benchmark) @@ -580,6 +581,47 @@ python -m featurevisor assess-distribution \ --populateUuid=deviceId ``` +## OpenFeature + +Install Featurevisor with its optional OpenFeature dependency: + +```bash +pip install "featurevisor[openfeature]" +``` + +```python +from featurevisor.openfeature import FeaturevisorOpenFeatureProvider +from openfeature import api +from openfeature.evaluation_context import EvaluationContext + +provider = FeaturevisorOpenFeatureProvider({"datafile": datafile_content}) +api.set_provider(provider) + +client = api.get_client() +enabled = client.get_boolean_value( + "checkout", + False, + EvaluationContext(targeting_key="user-123", attributes={"country": "nl"}), +) +``` + +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. + +OpenFeature's targeting key maps to `userId` by default. `targeting_key_field`, `key_separator`, and `variation_key` can customize the mapping. + +You can also reuse an existing Featurevisor instance: + +```python +from featurevisor import create_featurevisor + +featurevisor = create_featurevisor({"datafile": datafile_content}) +provider = FeaturevisorOpenFeatureProvider(featurevisor=featurevisor) +``` + +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. + +See the [OpenFeature provider guide](https://featurevisor.com/docs/sdks/openfeature/) for resolution reasons, errors, metadata, tracking, lifecycle, and providers for other languages. + ## Development diff --git a/pyproject.toml b/pyproject.toml index 93ff845..e5511a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,9 @@ classifiers = [ [project.scripts] featurevisor = "featurevisor.cli:main" +[project.optional-dependencies] +openfeature = ["openfeature-sdk>=0.10.0,<0.11"] + [tool.setuptools] package-dir = { "" = "src" } diff --git a/src/featurevisor/openfeature.py b/src/featurevisor/openfeature.py new file mode 100644 index 0000000..6507d8f --- /dev/null +++ b/src/featurevisor/openfeature.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import json +import math +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import Any, Callable + +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import ErrorCode +from openfeature.flag_evaluation import FlagResolutionDetails, Reason +from openfeature.provider import AbstractProvider +from openfeature.provider.metadata import Metadata +from openfeature.track import TrackingEventDetails + +from .instance import Featurevisor, create_featurevisor + + +class FeaturevisorOpenFeatureProvider(AbstractProvider): + """OpenFeature provider backed by the Featurevisor v3 SDK.""" + + def __init__( + self, + options: dict[str, Any] | None = None, + *, + featurevisor: Featurevisor | None = None, + targeting_key_field: str = "userId", + key_separator: str = ":", + variation_key: str = "variation", + on_track: Callable[[str, EvaluationContext | None, TrackingEventDetails | None], None] | None = None, + ) -> None: + super().__init__() + self.targeting_key_field = targeting_key_field or "userId" + self.key_separator = key_separator or ":" + self.variation_key = variation_key or "variation" + self.on_track = on_track + self.datafile_error: str | None = None + self._owns_featurevisor = featurevisor is None + if featurevisor is not None: + self.featurevisor = featurevisor + else: + featurevisor_options = dict(options or {}) + datafile = featurevisor_options.get("datafile") + if isinstance(datafile, str): + try: + json.loads(datafile) + except (TypeError, ValueError): + self.datafile_error = "Could not parse datafile" + original_handler = featurevisor_options.get("onDiagnostic") or featurevisor_options.get("on_diagnostic") + + def on_diagnostic(diagnostic: dict[str, Any]) -> None: + if diagnostic.get("code") == "invalid_datafile": + self.datafile_error = str(diagnostic.get("message")) + if diagnostic.get("code") == "datafile_set": + self.datafile_error = None + if original_handler: + original_handler(diagnostic) + + featurevisor_options["onDiagnostic"] = on_diagnostic + self.featurevisor = create_featurevisor(featurevisor_options) + self._datafile_unsubscribe = self.featurevisor.on("datafile_set", lambda _: setattr(self, "datafile_error", None)) + + def get_metadata(self) -> Metadata: + return Metadata(name="Featurevisor") + + def shutdown(self) -> None: + self._datafile_unsubscribe() + if self._owns_featurevisor: + self.featurevisor.close() + + def track( + self, + tracking_event_name: str, + evaluation_context: EvaluationContext | None = None, + tracking_event_details: TrackingEventDetails | None = None, + ) -> None: + if self.on_track: + self.on_track(tracking_event_name, evaluation_context, tracking_event_details) + + def resolve_boolean_details(self, flag_key: str, default_value: bool, evaluation_context: EvaluationContext | None = None) -> FlagResolutionDetails[bool]: + return self._resolve(flag_key, default_value, evaluation_context, "boolean") + + def resolve_string_details(self, flag_key: str, default_value: str, evaluation_context: EvaluationContext | None = None) -> FlagResolutionDetails[str]: + return self._resolve(flag_key, default_value, evaluation_context, "string") + + def resolve_integer_details(self, flag_key: str, default_value: int, evaluation_context: EvaluationContext | None = None) -> FlagResolutionDetails[int]: + return self._resolve(flag_key, default_value, evaluation_context, "integer") + + def resolve_float_details(self, flag_key: str, default_value: float, evaluation_context: EvaluationContext | None = None) -> FlagResolutionDetails[float]: + return self._resolve(flag_key, default_value, evaluation_context, "number") + + 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]]: + return self._resolve(flag_key, default_value, evaluation_context, "object") + + def _resolve(self, flag_key: str, default_value: Any, evaluation_context: EvaluationContext | None, expected_type: str) -> FlagResolutionDetails[Any]: + if self.datafile_error: + return self._error(default_value, ErrorCode.PARSE_ERROR, self.datafile_error) + + feature_key, separator, selector = flag_key.partition(self.key_separator) + selector = selector if separator else None + context = self._context(evaluation_context) + + if not selector: + if expected_type != "boolean": + return self._type_mismatch(flag_key, default_value, expected_type) + evaluation = self.featurevisor.evaluate_flag(feature_key, context) + value = evaluation.get("enabled") + elif selector == self.variation_key: + evaluation = self.featurevisor.evaluate_variation(feature_key, context) + value = evaluation.get("variationValue") + if value is None and evaluation.get("variation"): + value = evaluation["variation"].get("value") + else: + evaluation = self.featurevisor.evaluate_variable(feature_key, selector, context) + value = evaluation.get("variableValue") + if evaluation.get("variableSchema", {}).get("type") == "json" and isinstance(value, str): + try: + value = json.loads(value) + except (TypeError, ValueError): + pass + + metadata = self._metadata(evaluation) + error_code = self._error_code(evaluation.get("reason")) + if error_code: + return self._error(default_value, error_code, self._error_message(evaluation), metadata) + if value is None: + value = default_value + elif not self._matches(value, expected_type): + return self._type_mismatch(flag_key, default_value, expected_type, metadata) + + return FlagResolutionDetails( + value=value, + variant=self._variant(evaluation), + reason=self._reason(evaluation.get("reason")), + flag_metadata=metadata, + ) + + def _context(self, context: EvaluationContext | None) -> dict[str, Any]: + result = self._normalize(dict(context.attributes)) if context else {} + if context and context.targeting_key: + result[self.targeting_key_field] = context.targeting_key + return result + + def _metadata(self, evaluation: dict[str, Any]) -> dict[str, bool | int | float | str]: + metadata: dict[str, bool | int | float | str] = { + "featureKey": evaluation["featureKey"], + "featurevisorReason": evaluation["reason"], + "schemaVersion": self.featurevisor.get_schema_version(), + } + revision = self.featurevisor.get_revision() + if revision: + metadata["revision"] = revision + for key in ("variableKey", "ruleKey", "bucketKey", "bucketValue", "forceIndex", "variableOverrideIndex"): + if evaluation.get(key) is not None: + metadata[key] = evaluation[key] + return metadata + + @staticmethod + def _reason(reason: str | None) -> Reason: + if reason in {"feature_not_found", "variable_not_found", "no_variations", "error"}: + return Reason.ERROR + if reason in {"required", "forced", "sticky", "rule", "variable_override_variation", "variable_override_rule"}: + return Reason.TARGETING_MATCH + if reason == "allocated": + return Reason.SPLIT + if reason in {"disabled", "variation_disabled", "variable_disabled"}: + return Reason.DISABLED + return Reason.DEFAULT + + @staticmethod + def _error_code(reason: str | None) -> ErrorCode | None: + if reason in {"feature_not_found", "variable_not_found", "no_variations"}: + return ErrorCode.FLAG_NOT_FOUND + if reason == "error": + return ErrorCode.GENERAL + return None + + @staticmethod + def _error_message(evaluation: dict[str, Any]) -> str: + error = evaluation.get("error") + if error: + return str(error) + if evaluation.get("reason") == "feature_not_found": + return f'Feature "{evaluation["featureKey"]}" was not found' + if evaluation.get("reason") == "variable_not_found": + return f'Variable "{evaluation.get("variableKey")}" was not found for feature "{evaluation["featureKey"]}"' + if evaluation.get("reason") == "no_variations": + return f'Feature "{evaluation["featureKey"]}" has no variations' + return "Featurevisor evaluation failed" + + @staticmethod + def _variant(evaluation: dict[str, Any]) -> str | None: + if evaluation.get("variationValue") is not None: + return str(evaluation["variationValue"]) + if evaluation.get("variation"): + return str(evaluation["variation"].get("value")) + return None + + @staticmethod + def _matches(value: Any, expected_type: str) -> bool: + if expected_type == "boolean": + return isinstance(value, bool) + if expected_type == "string": + return isinstance(value, str) + if expected_type == "integer": + return isinstance(value, int) and not isinstance(value, bool) + if expected_type == "number": + return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) + return isinstance(value, (dict, list, tuple)) + + @classmethod + def _normalize(cls, value: Any) -> Any: + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, Mapping): + return {key: cls._normalize(item) for key, item in value.items()} + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return [cls._normalize(item) for item in value] + return value + + @staticmethod + def _error(value: Any, code: ErrorCode, message: str, metadata: Mapping[str, Any] | None = None) -> FlagResolutionDetails[Any]: + return FlagResolutionDetails(value=value, reason=Reason.ERROR, error_code=code, error_message=message, flag_metadata=metadata or {}) + + @classmethod + def _type_mismatch(cls, flag_key: str, value: Any, expected_type: str, metadata: Mapping[str, Any] | None = None) -> FlagResolutionDetails[Any]: + return cls._error(value, ErrorCode.TYPE_MISMATCH, f'Flag "{flag_key}" did not resolve to a {expected_type} value', metadata) diff --git a/tests_openfeature/test_provider.py b/tests_openfeature/test_provider.py new file mode 100644 index 0000000..3f1df2d --- /dev/null +++ b/tests_openfeature/test_provider.py @@ -0,0 +1,101 @@ +import unittest + +from openfeature import api +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import ErrorCode + +from featurevisor.openfeature import FeaturevisorOpenFeatureProvider +from featurevisor import create_featurevisor + + +DATAFILE = { + "schemaVersion": "2", + "revision": "openfeature-test", + "segments": {}, + "features": { + "checkout": { + "bucketBy": "userId", + "variations": [{ + "value": "on", + "variables": { + "title": "Hello", "count": 3, "ratio": 1.5, "visible": True, + "items": ["a"], "config": {"color": "blue"}, "json": '{"nested":true}', + }, + }], + "variablesSchema": { + "title": {"type": "string", "defaultValue": "Default"}, + "count": {"type": "integer", "defaultValue": 0}, + "ratio": {"type": "double", "defaultValue": 0}, + "visible": {"type": "boolean", "defaultValue": False}, + "items": {"type": "array", "defaultValue": []}, + "config": {"type": "object", "defaultValue": {}}, + "json": {"type": "json", "defaultValue": "{}"}, + }, + "force": [{"conditions": {"attribute": "userId", "operator": "equals", "value": "forced-user"}, "enabled": True, "variation": "on"}], + "traffic": [{"key": "all", "segments": "*", "percentage": 100000, "variation": "on"}], + } + }, +} + + +class OpenFeatureProviderTest(unittest.TestCase): + def provider(self, **kwargs): + return FeaturevisorOpenFeatureProvider({"datafile": DATAFILE, "logLevel": "fatal"}, **kwargs) + + def test_resolves_every_type_and_maps_targeting_key(self): + provider = self.provider() + context = EvaluationContext(targeting_key="forced-user") + self.assertTrue(provider.resolve_boolean_details("checkout", False, context).value) + self.assertEqual(provider.resolve_string_details("checkout:variation", "fallback", context).value, "on") + self.assertEqual(provider.resolve_string_details("checkout:title", "fallback", context).value, "Hello") + self.assertEqual(provider.resolve_integer_details("checkout:count", 0, context).value, 3) + self.assertEqual(provider.resolve_float_details("checkout:ratio", 0, context).value, 1.5) + self.assertTrue(provider.resolve_boolean_details("checkout:visible", False, context).value) + self.assertEqual(provider.resolve_object_details("checkout:items", [], context).value, ["a"]) + self.assertEqual(provider.resolve_object_details("checkout:config", {}, context).value, {"color": "blue"}) + self.assertEqual(provider.resolve_object_details("checkout:json", {}, context).value, {"nested": True}) + + def test_errors_custom_grammar_tracking_and_shutdown(self): + tracked = [] + provider = self.provider(key_separator="/", variation_key="$variation", on_track=lambda *args: tracked.append(args)) + self.assertEqual(provider.resolve_string_details("checkout/$variation", "fallback").value, "on") + self.assertEqual(provider.resolve_string_details("missing", "fallback").error_code, ErrorCode.TYPE_MISMATCH) + missing = provider.resolve_boolean_details("missing", True) + self.assertTrue(missing.value) + self.assertEqual(missing.error_code, ErrorCode.FLAG_NOT_FOUND) + provider.track("purchase", EvaluationContext(targeting_key="u"), None) + self.assertEqual(tracked[0][0], "purchase") + provider.shutdown() + + def test_malformed_datafile(self): + provider = FeaturevisorOpenFeatureProvider({"datafile": "{", "logLevel": "fatal"}) + result = provider.resolve_boolean_details("checkout", False) + self.assertEqual(result.error_code, ErrorCode.PARSE_ERROR) + self.assertEqual(result.error_message, "Could not parse datafile") + provider.featurevisor.set_datafile(DATAFILE, replace=True) + self.assertTrue(provider.resolve_boolean_details("checkout", False, EvaluationContext(targeting_key="forced-user")).value) + + def test_works_through_openfeature_api(self): + api.set_provider(self.provider()) + client = api.get_client() + self.assertTrue(client.get_boolean_value("checkout", False, EvaluationContext(targeting_key="forced-user"))) + + def test_borrows_existing_featurevisor(self): + closed = [] + featurevisor = create_featurevisor({ + "datafile": DATAFILE, + "logLevel": "fatal", + "modules": [{"name": "owner", "close": lambda: closed.append(True)}], + }) + provider = FeaturevisorOpenFeatureProvider(featurevisor=featurevisor) + + self.assertIs(provider.featurevisor, featurevisor) + provider.shutdown() + self.assertEqual(closed, []) + + featurevisor.close() + self.assertEqual(closed, [True]) + + +if __name__ == "__main__": + unittest.main()