|
| 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