From 7a4b79abea1e3b7dfb9741618c1c45fe2d25423a Mon Sep 17 00:00:00 2001 From: WuJiayi0307 Date: Wed, 22 Jul 2026 22:48:48 +0800 Subject: [PATCH 1/5] feat: add FeatBit OpenFeature Python server provider --- .flake8 | 3 + .gitignore | 6 + README.md | 74 +++++ featbit_openfeature/__init__.py | 4 + featbit_openfeature/impl/__init__.py | 1 + featbit_openfeature/impl/context_converter.py | 74 +++++ featbit_openfeature/impl/details_converter.py | 59 ++++ featbit_openfeature/provider.py | 257 +++++++++++++++++ featbit_openfeature/py.typed | 1 + pyproject.toml | 29 ++ tests/fixtures/bootstrap.json | 41 +++ tests/impl/test_context_converter.py | 96 +++++++ tests/impl/test_details_converter.py | 68 +++++ tests/test_offline_integration.py | 38 +++ tests/test_provider.py | 260 ++++++++++++++++++ 15 files changed, 1011 insertions(+) create mode 100644 .flake8 create mode 100644 .gitignore create mode 100644 README.md create mode 100644 featbit_openfeature/__init__.py create mode 100644 featbit_openfeature/impl/__init__.py create mode 100644 featbit_openfeature/impl/context_converter.py create mode 100644 featbit_openfeature/impl/details_converter.py create mode 100644 featbit_openfeature/provider.py create mode 100644 featbit_openfeature/py.typed create mode 100644 pyproject.toml create mode 100644 tests/fixtures/bootstrap.json create mode 100644 tests/impl/test_context_converter.py create mode 100644 tests/impl/test_details_converter.py create mode 100644 tests/test_offline_integration.py create mode 100644 tests/test_provider.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..f79c467 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 100 +exclude = .git,.venv,build,dist diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..982bbd1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +.pytest_cache/ +.venv/ +build/ +dist/ +*.egg-info/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..173ce4a --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# FeatBit OpenFeature Provider for Python Server SDK + +Thin OpenFeature provider backed by an existing FeatBit Python Server SDK client. +The provider converts OpenFeature evaluation contexts and resolution details; feature +flag synchronization, local evaluation, and insight events remain owned by +`fb-python-sdk`. + +## Installation + +```shell +pip install featbit-openfeature-server +``` + +The initial implementation requires a FeatBit Python SDK release that exposes +`EvalDetail.variation_id` (planned as `fb-python-sdk>=1.1.8`). + +## Usage + +```python +from fbclient.client import FBClient +from fbclient.config import Config +from openfeature import api +from openfeature.evaluation_context import EvaluationContext + +from featbit_openfeature import FeatBitProvider + +fb_client = FBClient( + Config( + "server-side-environment-secret", + event_url="https://app-eval.featbit.co", + streaming_url="wss://app-eval.featbit.co", + ), + start_wait=0, +) + +api.set_provider_and_wait(FeatBitProvider(fb_client)) +client = api.get_client() + +details = client.get_boolean_details( + "new-checkout", + False, + EvaluationContext( + targeting_key="user-123", + attributes={"name": "Alice", "plan": "beta"}, + ), +) + +print(details.value, details.reason, details.variant) + +api.shutdown() +fb_client.stop() +``` + +The application owns the injected `FBClient`. Calling `api.shutdown()` removes +provider listeners but does not stop the FeatBit client; call `fb_client.stop()` +afterward so queued events are flushed and background resources are released. + +## Supported mappings + +- Boolean, string, integer, float, and object resolution. +- OpenFeature targeting key and scalar attributes to a FeatBit user dictionary. +- FeatBit reasons and failures to OpenFeature `Reason` and `ErrorCode`. +- FeatBit variation ID to OpenFeature `variant`. +- FeatBit flag changes to `PROVIDER_CONFIGURATION_CHANGED`. +- OpenFeature tracking value to `FBClient.track_metric()`. + +Structured evaluation-context attributes and tracking custom attributes are not +supported by the current FeatBit user and metric schemas. They are ignored rather +than represented inaccurately. + +## Development tests + +Unit tests use mocks, and the integration test uses FeatBit offline data. Neither +requires a FeatBit account. diff --git a/featbit_openfeature/__init__.py b/featbit_openfeature/__init__.py new file mode 100644 index 0000000..3f17247 --- /dev/null +++ b/featbit_openfeature/__init__.py @@ -0,0 +1,4 @@ +from featbit_openfeature.provider import FeatBitProvider + + +__all__ = ["FeatBitProvider"] diff --git a/featbit_openfeature/impl/__init__.py b/featbit_openfeature/impl/__init__.py new file mode 100644 index 0000000..5b1b5e2 --- /dev/null +++ b/featbit_openfeature/impl/__init__.py @@ -0,0 +1 @@ +"""Internal converters used by the FeatBit OpenFeature provider.""" diff --git a/featbit_openfeature/impl/context_converter.py b/featbit_openfeature/impl/context_converter.py new file mode 100644 index 0000000..4b079a4 --- /dev/null +++ b/featbit_openfeature/impl/context_converter.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Any + +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import TargetingKeyMissingError + + +logger = logging.getLogger("featbit-openfeature-server") + +_RESERVED_ATTRIBUTES = {"key", "keyid", "targetingKey", "name"} + + +class EvaluationContextConverter: + """Converts an OpenFeature evaluation context to a FeatBit user mapping.""" + + def to_fb_user(self, context: EvaluationContext | None) -> dict[str, Any]: + if context is None: + raise TargetingKeyMissingError("evaluation context is required") + + attributes = dict(context.attributes) + targeting_key = self._targeting_key(context, attributes) + name = attributes.get("name") + if not self._is_non_empty_string(name): + if name is not None: + logger.warning("FeatBit user name must be a non-empty string; using targeting key") + name = targeting_key + + user: dict[str, Any] = {"key": targeting_key, "name": name} + for key, value in attributes.items(): + if not isinstance(key, str) or key in _RESERVED_ATTRIBUTES: + continue + + converted = self._convert_attribute(value) + if converted is _UNSUPPORTED_ATTRIBUTE: + logger.debug("Ignoring unsupported structured EvaluationContext attribute %r", key) + continue + user[key] = converted + + return user + + @classmethod + def _targeting_key( + cls, context: EvaluationContext, attributes: dict[str, Any] + ) -> str: + if cls._is_non_empty_string(context.targeting_key): + return context.targeting_key # type: ignore[return-value] + + attribute_key = attributes.get("key") + if cls._is_non_empty_string(attribute_key): + return attribute_key + + raise TargetingKeyMissingError( + "evaluation context must contain a non-empty targeting key" + ) + + @staticmethod + def _is_non_empty_string(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) + + @staticmethod + def _convert_attribute(value: Any) -> Any: + if isinstance(value, datetime): + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.isoformat() + if isinstance(value, (str, bool, int, float)): + return value + return _UNSUPPORTED_ATTRIBUTE + + +_UNSUPPORTED_ATTRIBUTE = object() diff --git a/featbit_openfeature/impl/details_converter.py b/featbit_openfeature/impl/details_converter.py new file mode 100644 index 0000000..376f466 --- /dev/null +++ b/featbit_openfeature/impl/details_converter.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import Any + +from fbclient.common_types import EvalDetail +from fbclient.evaluator import ( + REASON_CLIENT_NOT_READY, + REASON_ERROR, + REASON_FLAG_NOT_FOUND, + REASON_FLAG_OFF, + REASON_RULE_MATCH, + REASON_TARGET_MATCH, + REASON_USER_NOT_SPECIFIED, + REASON_WRONG_TYPE, +) +from openfeature.exception import ErrorCode +from openfeature.flag_evaluation import FlagResolutionDetails, Reason + + +_STANDARD_REASONS = { + REASON_FLAG_OFF: Reason.DISABLED, + REASON_TARGET_MATCH: Reason.TARGETING_MATCH, + REASON_RULE_MATCH: Reason.TARGETING_MATCH, +} + +_ERROR_CODES = { + REASON_CLIENT_NOT_READY: ErrorCode.PROVIDER_NOT_READY, + REASON_FLAG_NOT_FOUND: ErrorCode.FLAG_NOT_FOUND, + REASON_USER_NOT_SPECIFIED: ErrorCode.TARGETING_KEY_MISSING, + REASON_WRONG_TYPE: ErrorCode.TYPE_MISMATCH, + REASON_ERROR: ErrorCode.GENERAL, +} + + +class ResolutionDetailsConverter: + """Converts FeatBit evaluation details to OpenFeature resolution details.""" + + def to_resolution_details( + self, result: EvalDetail, resolved_value: Any + ) -> FlagResolutionDetails[Any]: + raw_reason = result.reason or "" + error_code = _ERROR_CODES.get(raw_reason) + + if error_code is not None: + reason: str | Reason = Reason.ERROR + error_message = raw_reason + variant = None + else: + reason = _STANDARD_REASONS.get(raw_reason, raw_reason or Reason.UNKNOWN) + error_message = None + variant = result.variation_id + + return FlagResolutionDetails( + value=resolved_value, + error_code=error_code, + error_message=error_message, + reason=reason, + variant=variant, + ) diff --git a/featbit_openfeature/provider.py b/featbit_openfeature/provider.py new file mode 100644 index 0000000..8cb479a --- /dev/null +++ b/featbit_openfeature/provider.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import logging +import threading +from collections.abc import Mapping, Sequence +from typing import Any + +from fbclient.client import FBClient +from fbclient.flag_change_notification import FlagChangedListener, FlagChangedNotice +from fbclient.status_types import StateType +from openfeature.evaluation_context import EvaluationContext +from openfeature.event import ProviderEventDetails +from openfeature.exception import ( + ErrorCode, + ProviderFatalError, + ProviderNotReadyError, + TargetingKeyMissingError, +) +from openfeature.flag_evaluation import ( + FlagResolutionDetails, + FlagType, + FlagValueType, + Reason, +) +from openfeature.provider import AbstractProvider +from openfeature.provider.metadata import Metadata +from openfeature.track import TrackingEventDetails + +from featbit_openfeature.impl.context_converter import EvaluationContextConverter +from featbit_openfeature.impl.details_converter import ResolutionDetailsConverter + + +logger = logging.getLogger("featbit-openfeature-server") + +_TYPE_MISMATCH = object() + + +class _FlagChangeListener(FlagChangedListener): + def __init__(self, callback: Any): + self._callback = callback + + def on_flag_change(self, notice: FlagChangedNotice): + self._callback(notice.flag_key) + + +class FeatBitProvider(AbstractProvider): + """OpenFeature provider backed by an existing FeatBit ``FBClient``.""" + + def __init__(self, client: FBClient, initialization_timeout: float = 15.0): + super().__init__() + if client is None: + raise ValueError("client is required") + if initialization_timeout <= 0: + raise ValueError("initialization_timeout must be greater than zero") + + self._client = client + self._initialization_timeout = initialization_timeout + self._context_converter = EvaluationContextConverter() + self._details_converter = ResolutionDetailsConverter() + self._lifecycle_lock = threading.Lock() + self._flag_change_listener: _FlagChangeListener | None = None + + @property + def client(self) -> FBClient: + """Returns the FeatBit client supplied to this provider.""" + return self._client + + def initialize(self, evaluation_context: EvaluationContext) -> None: + if not self._client.initialize: + ready = self._client.update_status_provider.wait_for_OKState( + self._initialization_timeout + ) + if not ready: + state = self._client.update_status_provider.current_state + message = self._state_message(state) + if state.state_type == StateType.OFF: + raise ProviderFatalError(message) + raise ProviderNotReadyError(message) + + self._register_flag_change_listener() + + def shutdown(self) -> None: + with self._lifecycle_lock: + listener = self._flag_change_listener + self._flag_change_listener = None + + if listener is None: + return + try: + self._client.flag_tracker.remove_flag_change_notifier(listener) + except Exception: + logger.exception("Failed to remove FeatBit flag change listener") + + def get_metadata(self) -> Metadata: + return Metadata("featbit-openfeature-server") + + def resolve_boolean_details( + self, + flag_key: str, + default_value: bool, + evaluation_context: EvaluationContext | None = None, + ) -> FlagResolutionDetails[bool]: + return self._resolve_value( + FlagType.BOOLEAN, flag_key, default_value, evaluation_context + ) + + def resolve_string_details( + self, + flag_key: str, + default_value: str, + evaluation_context: EvaluationContext | None = None, + ) -> FlagResolutionDetails[str]: + return self._resolve_value( + FlagType.STRING, flag_key, default_value, evaluation_context + ) + + def resolve_integer_details( + self, + flag_key: str, + default_value: int, + evaluation_context: EvaluationContext | None = None, + ) -> FlagResolutionDetails[int]: + return self._resolve_value( + FlagType.INTEGER, flag_key, default_value, evaluation_context + ) + + def resolve_float_details( + self, + flag_key: str, + default_value: float, + evaluation_context: EvaluationContext | None = None, + ) -> FlagResolutionDetails[float]: + return self._resolve_value( + FlagType.FLOAT, flag_key, default_value, evaluation_context + ) + + def resolve_object_details( + self, + flag_key: str, + default_value: Sequence[FlagValueType] | Mapping[str, FlagValueType], + evaluation_context: EvaluationContext | None = None, + ) -> FlagResolutionDetails[ + Sequence[FlagValueType] | Mapping[str, FlagValueType] + ]: + return self._resolve_value( + FlagType.OBJECT, flag_key, default_value, evaluation_context + ) + + def track( + self, + tracking_event_name: str, + evaluation_context: EvaluationContext | None = None, + tracking_event_details: TrackingEventDetails | None = None, + ) -> None: + try: + user = self._context_converter.to_fb_user(evaluation_context) + metric_value = 1.0 + if tracking_event_details is not None: + if tracking_event_details.attributes: + logger.debug( + "FeatBit track_metric does not support tracking attributes; ignoring them" + ) + if tracking_event_details.value is not None: + metric_value = float(tracking_event_details.value) + self._client.track_metric(user, tracking_event_name, metric_value) + except TargetingKeyMissingError: + logger.warning("Ignoring FeatBit tracking call without a targeting key") + except Exception: + logger.exception("FeatBit tracking failed") + + def _resolve_value( + self, + flag_type: FlagType, + flag_key: str, + default_value: Any, + evaluation_context: EvaluationContext | None, + ) -> FlagResolutionDetails[Any]: + try: + user = self._context_converter.to_fb_user(evaluation_context) + result = self._client.variation_detail(flag_key, user, default_value) + resolved_value = self._validate_and_cast_value( + flag_type, result.variation + ) + if resolved_value is _TYPE_MISMATCH: + return self._type_mismatch_details(default_value) + return self._details_converter.to_resolution_details( + result, resolved_value + ) + except TargetingKeyMissingError as error: + return FlagResolutionDetails( + value=default_value, + reason=Reason.ERROR, + error_code=ErrorCode.TARGETING_KEY_MISSING, + error_message=error.error_message, + ) + except Exception: + logger.exception("FeatBit flag evaluation failed") + return FlagResolutionDetails( + value=default_value, + reason=Reason.ERROR, + error_code=ErrorCode.GENERAL, + error_message="FeatBit flag evaluation failed", + ) + + @staticmethod + def _validate_and_cast_value(flag_type: FlagType, value: Any) -> Any: + if flag_type == FlagType.BOOLEAN and isinstance(value, bool): + return value + if flag_type == FlagType.STRING and isinstance(value, str): + return value + if ( + flag_type == FlagType.INTEGER + and isinstance(value, (int, float)) + and not isinstance(value, bool) + ): + return int(value) + if ( + flag_type == FlagType.FLOAT + and isinstance(value, (int, float)) + and not isinstance(value, bool) + ): + return float(value) + if flag_type == FlagType.OBJECT and isinstance(value, (dict, list)): + return value + return _TYPE_MISMATCH + + @staticmethod + def _type_mismatch_details(default_value: Any) -> FlagResolutionDetails[Any]: + return FlagResolutionDetails( + value=default_value, + reason=Reason.ERROR, + error_code=ErrorCode.TYPE_MISMATCH, + error_message="FeatBit flag value does not match the requested type", + ) + + def _register_flag_change_listener(self) -> None: + with self._lifecycle_lock: + if self._flag_change_listener is not None: + return + listener = _FlagChangeListener(self._emit_configuration_changed) + try: + self._client.flag_tracker.add_flag_changed_listener(listener) + except Exception: + logger.exception("Failed to register FeatBit flag change listener") + return + self._flag_change_listener = listener + + def _emit_configuration_changed(self, flag_key: str) -> None: + self.emit_provider_configuration_changed( + ProviderEventDetails(flags_changed=[flag_key]) + ) + + @staticmethod + def _state_message(state: Any) -> str: + error = getattr(state, "error_track", None) + message = getattr(error, "message", None) + return message or "FeatBit client initialization did not complete" diff --git a/featbit_openfeature/py.typed b/featbit_openfeature/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/featbit_openfeature/py.typed @@ -0,0 +1 @@ + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..930b8f5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools>=65"] +build-backend = "setuptools.build_meta" + +[project] +name = "featbit-openfeature-server" +version = "0.1.0" +description = "OpenFeature provider for the FeatBit Python server SDK" +readme = "README.md" +requires-python = ">=3.10" +license = {text = "MIT"} +authors = [{name = "FeatBit"}] +dependencies = [ + "openfeature-sdk>=0.10,<1", + "fb-python-sdk>=1.1.8,<2", +] + +[project.urls] +Homepage = "https://github.com/featbit/featbit-python-sdk" +Issues = "https://github.com/featbit/featbit-python-sdk/issues" + +[tool.setuptools.packages.find] +include = ["featbit_openfeature*"] + +[tool.setuptools.package-data] +featbit_openfeature = ["py.typed"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/fixtures/bootstrap.json b/tests/fixtures/bootstrap.json new file mode 100644 index 0000000..75d5de2 --- /dev/null +++ b/tests/fixtures/bootstrap.json @@ -0,0 +1,41 @@ +{ + "messageType": "data-sync", + "data": { + "eventType": "full", + "featureFlags": [ + { + "envId": "test-environment", + "name": "Python App Progressive Release", + "key": "python-app-release", + "variationType": "string", + "variations": [ + {"id": "variation-v2", "value": "v2"}, + {"id": "variation-v1", "value": "v1"} + ], + "targetUsers": [ + {"keyIds": ["employee-001"], "variationId": "variation-v2"} + ], + "rules": [], + "isEnabled": true, + "disabledVariationId": "variation-v1", + "fallthrough": { + "dispatchKey": null, + "includedInExpt": false, + "variations": [ + {"id": "variation-v1", "rollout": [0, 1], "exptRollout": 1} + ] + }, + "exptIncludeAllTargets": true, + "tags": [], + "isArchived": false, + "disabledVariation": {"id": "variation-v1", "value": "v1"}, + "creatorId": "test", + "updatorId": "test", + "createdAt": "2026-07-22T00:00:00Z", + "updatedAt": "2026-07-22T00:00:00Z", + "id": "python-app-release-id" + } + ], + "segments": [] + } +} diff --git a/tests/impl/test_context_converter.py b/tests/impl/test_context_converter.py new file mode 100644 index 0000000..83c2b05 --- /dev/null +++ b/tests/impl/test_context_converter.py @@ -0,0 +1,96 @@ +import logging +from datetime import datetime, timedelta, timezone + +import pytest +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import TargetingKeyMissingError + +from featbit_openfeature.impl.context_converter import EvaluationContextConverter + + +@pytest.fixture +def converter() -> EvaluationContextConverter: + return EvaluationContextConverter() + + +def test_targeting_key_and_name_are_converted(converter): + context = EvaluationContext("user-123", {"name": "Alice", "plan": "beta"}) + + assert converter.to_fb_user(context) == { + "key": "user-123", + "name": "Alice", + "plan": "beta", + } + + +def test_attribute_key_is_supported_as_compatibility_fallback(converter): + context = EvaluationContext(None, {"key": "legacy-user"}) + + assert converter.to_fb_user(context) == { + "key": "legacy-user", + "name": "legacy-user", + } + + +def test_targeting_key_takes_precedence_over_attribute_key(converter): + context = EvaluationContext("preferred", {"key": "legacy", "name": "Alice"}) + + user = converter.to_fb_user(context) + + assert user["key"] == "preferred" + assert "keyid" not in user + assert "targetingKey" not in user + + +@pytest.mark.parametrize("context", [None, EvaluationContext(), EvaluationContext(" ")]) +def test_missing_targeting_key_is_rejected(converter, context): + with pytest.raises(TargetingKeyMissingError): + converter.to_fb_user(context) + + +def test_invalid_name_falls_back_to_targeting_key(converter, caplog): + context = EvaluationContext("user-123", {"name": 42}) + + user = converter.to_fb_user(context) + + assert user["name"] == "user-123" + assert "using targeting key" in caplog.records[0].message + + +def test_scalar_attributes_are_preserved(converter): + context = EvaluationContext( + "user-123", + {"string": "value", "boolean": True, "integer": 3, "float": 1.5}, + ) + + user = converter.to_fb_user(context) + + assert user["string"] == "value" + assert user["boolean"] is True + assert user["integer"] == 3 + assert user["float"] == 1.5 + + +def test_datetime_attributes_are_iso_formatted(converter): + naive = datetime(2026, 7, 22, 12, 30) + aware = datetime(2026, 7, 22, 12, 30, tzinfo=timezone(timedelta(hours=8))) + context = EvaluationContext("user-123", {"naive": naive, "aware": aware}) + + user = converter.to_fb_user(context) + + assert user["naive"] == "2026-07-22T12:30:00+00:00" + assert user["aware"] == "2026-07-22T12:30:00+08:00" + + +def test_structured_attributes_are_ignored_without_mutating_context(converter, caplog): + caplog.set_level(logging.DEBUG, logger="featbit-openfeature-server") + attributes = {"groups": ["beta"], "profile": {"region": "cn"}, "plan": "pro"} + context = EvaluationContext("user-123", attributes) + + user = converter.to_fb_user(context) + + assert user["plan"] == "pro" + assert "groups" not in user + assert "profile" not in user + assert context.attributes == attributes + assert len(caplog.records) == 2 diff --git a/tests/impl/test_details_converter.py b/tests/impl/test_details_converter.py new file mode 100644 index 0000000..7c44616 --- /dev/null +++ b/tests/impl/test_details_converter.py @@ -0,0 +1,68 @@ +import pytest +from fbclient.common_types import EvalDetail +from fbclient.evaluator import ( + REASON_CLIENT_NOT_READY, + REASON_ERROR, + REASON_FALLTHROUGH, + REASON_FLAG_NOT_FOUND, + REASON_FLAG_OFF, + REASON_PREREQUISITE_FAILED, + REASON_RULE_MATCH, + REASON_TARGET_MATCH, + REASON_USER_NOT_SPECIFIED, + REASON_WRONG_TYPE, +) +from openfeature.exception import ErrorCode +from openfeature.flag_evaluation import Reason + +from featbit_openfeature.impl.details_converter import ResolutionDetailsConverter + + +@pytest.fixture +def converter() -> ResolutionDetailsConverter: + return ResolutionDetailsConverter() + + +@pytest.mark.parametrize( + "featbit_reason,openfeature_reason", + [ + (REASON_FLAG_OFF, Reason.DISABLED), + (REASON_TARGET_MATCH, Reason.TARGETING_MATCH), + (REASON_RULE_MATCH, Reason.TARGETING_MATCH), + (REASON_FALLTHROUGH, REASON_FALLTHROUGH), + (REASON_PREREQUISITE_FAILED, REASON_PREREQUISITE_FAILED), + ("vendor-specific-reason", "vendor-specific-reason"), + ("", Reason.UNKNOWN), + ], +) +def test_success_reason_mapping(converter, featbit_reason, openfeature_reason): + detail = EvalDetail(featbit_reason, True, "flag", "Flag", "variation-id") + + result = converter.to_resolution_details(detail, True) + + assert result.value is True + assert result.reason == openfeature_reason + assert result.error_code is None + assert result.error_message is None + assert result.variant == "variation-id" + + +@pytest.mark.parametrize( + "featbit_reason,error_code", + [ + (REASON_CLIENT_NOT_READY, ErrorCode.PROVIDER_NOT_READY), + (REASON_FLAG_NOT_FOUND, ErrorCode.FLAG_NOT_FOUND), + (REASON_USER_NOT_SPECIFIED, ErrorCode.TARGETING_KEY_MISSING), + (REASON_WRONG_TYPE, ErrorCode.TYPE_MISMATCH), + (REASON_ERROR, ErrorCode.GENERAL), + ], +) +def test_error_mapping_does_not_return_variant(converter, featbit_reason, error_code): + detail = EvalDetail(featbit_reason, False, "flag", "Flag", "must-not-leak") + + result = converter.to_resolution_details(detail, False) + + assert result.reason == Reason.ERROR + assert result.error_code == error_code + assert result.error_message == featbit_reason + assert result.variant is None diff --git a/tests/test_offline_integration.py b/tests/test_offline_integration.py new file mode 100644 index 0000000..56bceb2 --- /dev/null +++ b/tests/test_offline_integration.py @@ -0,0 +1,38 @@ +from pathlib import Path + +from fbclient.client import FBClient +from fbclient.config import Config +from openfeature import api +from openfeature.evaluation_context import EvaluationContext +from openfeature.flag_evaluation import Reason + +from featbit_openfeature import FeatBitProvider + + +def test_openfeature_client_evaluates_with_real_featbit_sdk_offline(): + data = Path(__file__).parent.joinpath("fixtures", "bootstrap.json").read_text( + encoding="utf-8" + ) + config = Config("offline-secret", "http://offline", "http://offline", offline=True) + fb_client = FBClient(config, start_wait=0) + try: + assert fb_client.initialize_from_external_json(data) + assert fb_client.initialize + + api.set_provider_and_wait(FeatBitProvider(fb_client)) + client = api.get_client() + details = client.get_string_details( + "python-app-release", + "v1", + EvaluationContext( + "employee-001", {"name": "Employee", "plan": "standard"} + ), + ) + + assert details.value == "v2" + assert details.reason == Reason.TARGETING_MATCH + assert details.variant == "variation-v2" + assert details.error_code is None + finally: + api.shutdown() + fb_client.stop() diff --git a/tests/test_provider.py b/tests/test_provider.py new file mode 100644 index 0000000..d0614e7 --- /dev/null +++ b/tests/test_provider.py @@ -0,0 +1,260 @@ +from concurrent.futures import ThreadPoolExecutor +from unittest.mock import Mock + +import pytest +from fbclient.common_types import EvalDetail +from fbclient.evaluator import REASON_CLIENT_NOT_READY, REASON_TARGET_MATCH +from fbclient.flag_change_notification import FlagChangedNotice +from fbclient.status_types import State +from openfeature.evaluation_context import EvaluationContext +from openfeature.event import ProviderEvent +from openfeature.exception import ( + ErrorCode, + ProviderFatalError, + ProviderNotReadyError, +) +from openfeature.flag_evaluation import Reason +from openfeature.track import TrackingEventDetails + +from featbit_openfeature import FeatBitProvider + + +def make_client(initialized=True): + client = Mock() + client.initialize = initialized + client.flag_tracker = Mock() + client.update_status_provider = Mock() + return client + + +def make_context(): + return EvaluationContext("user-123", {"name": "Alice", "plan": "beta"}) + + +def test_metadata_and_client_access(): + client = make_client() + provider = FeatBitProvider(client) + + assert provider.get_metadata().name == "featbit-openfeature-server" + assert provider.client is client + + +def test_constructor_validation(): + with pytest.raises(ValueError): + FeatBitProvider(None) # type: ignore[arg-type] + with pytest.raises(ValueError): + FeatBitProvider(make_client(), initialization_timeout=0) + + +@pytest.mark.parametrize( + "method_name,default_value,return_value,expected_value", + [ + ("resolve_boolean_details", False, True, True), + ("resolve_string_details", "default", "enabled", "enabled"), + ("resolve_integer_details", 1, 2.9, 2), + ("resolve_float_details", 1.0, 2, 2.0), + ("resolve_object_details", {"default": True}, {"enabled": True}, {"enabled": True}), + ("resolve_object_details", ["default"], ["enabled"], ["enabled"]), + ], +) +def test_typed_resolution_methods(method_name, default_value, return_value, expected_value): + client = make_client() + client.variation_detail.return_value = EvalDetail( + REASON_TARGET_MATCH, return_value, "flag-key", "Flag", "variation-id" + ) + provider = FeatBitProvider(client) + + result = getattr(provider, method_name)("flag-key", default_value, make_context()) + + assert result.value == expected_value + assert result.reason == Reason.TARGETING_MATCH + assert result.variant == "variation-id" + client.variation_detail.assert_called_once_with( + "flag-key", + {"key": "user-123", "name": "Alice", "plan": "beta"}, + default_value, + ) + + +@pytest.mark.parametrize( + "method_name,default_value,return_value", + [ + ("resolve_boolean_details", False, 1), + ("resolve_boolean_details", False, "true"), + ("resolve_string_details", "default", True), + ("resolve_integer_details", 1, True), + ("resolve_float_details", 1.0, True), + ("resolve_object_details", {}, "json"), + ], +) +def test_type_mismatch_returns_default(method_name, default_value, return_value): + client = make_client() + client.variation_detail.return_value = EvalDetail( + REASON_TARGET_MATCH, return_value, "flag-key", "Flag", "variation-id" + ) + provider = FeatBitProvider(client) + + result = getattr(provider, method_name)("flag-key", default_value, make_context()) + + assert result.value == default_value + assert result.reason == Reason.ERROR + assert result.error_code == ErrorCode.TYPE_MISMATCH + assert result.variant is None + + +def test_missing_context_returns_default_without_calling_featbit(): + client = make_client() + provider = FeatBitProvider(client) + + result = provider.resolve_boolean_details("flag-key", False, None) + + assert result.value is False + assert result.reason == Reason.ERROR + assert result.error_code == ErrorCode.TARGETING_KEY_MISSING + client.variation_detail.assert_not_called() + + +def test_featbit_error_details_are_preserved(): + client = make_client() + client.variation_detail.return_value = EvalDetail( + REASON_CLIENT_NOT_READY, False, "flag-key", "Flag" + ) + provider = FeatBitProvider(client) + + result = provider.resolve_boolean_details("flag-key", False, make_context()) + + assert result.value is False + assert result.reason == Reason.ERROR + assert result.error_code == ErrorCode.PROVIDER_NOT_READY + assert result.error_message == REASON_CLIENT_NOT_READY + + +def test_unexpected_evaluation_exception_never_escapes(): + client = make_client() + client.variation_detail.side_effect = RuntimeError("boom") + provider = FeatBitProvider(client) + + result = provider.resolve_string_details("flag-key", "safe", make_context()) + + assert result.value == "safe" + assert result.error_code == ErrorCode.GENERAL + assert result.reason == Reason.ERROR + + +def test_initialize_returns_immediately_when_client_is_ready(): + client = make_client(initialized=True) + provider = FeatBitProvider(client) + + provider.initialize(EvaluationContext()) + + client.update_status_provider.wait_for_OKState.assert_not_called() + client.flag_tracker.add_flag_changed_listener.assert_called_once() + + +def test_initialize_waits_for_ready_client(): + client = make_client(initialized=False) + client.update_status_provider.wait_for_OKState.return_value = True + provider = FeatBitProvider(client, initialization_timeout=3) + + provider.initialize(EvaluationContext()) + + client.update_status_provider.wait_for_OKState.assert_called_once_with(3) + + +def test_initialize_timeout_raises_provider_not_ready(): + client = make_client(initialized=False) + client.update_status_provider.wait_for_OKState.return_value = False + client.update_status_provider.current_state = State.intializing_state() + provider = FeatBitProvider(client, initialization_timeout=3) + + with pytest.raises(ProviderNotReadyError): + provider.initialize(EvaluationContext()) + + +def test_initialize_off_state_raises_provider_fatal(): + client = make_client(initialized=False) + client.update_status_provider.wait_for_OKState.return_value = False + client.update_status_provider.current_state = State.error_off_state( + "credential", "invalid environment secret" + ) + provider = FeatBitProvider(client, initialization_timeout=3) + + with pytest.raises(ProviderFatalError) as error: + provider.initialize(EvaluationContext()) + + assert error.value.error_message == "invalid environment secret" + + +def test_flag_change_is_emitted_and_shutdown_removes_listener(): + client = make_client() + provider = FeatBitProvider(client) + emitter = Mock() + provider.attach(emitter) + provider.initialize(EvaluationContext()) + listener = client.flag_tracker.add_flag_changed_listener.call_args.args[0] + + listener.on_flag_change(FlagChangedNotice("checkout")) + provider.shutdown() + provider.shutdown() + + emitted_provider, event, details = emitter.call_args.args + assert emitted_provider is provider + assert event == ProviderEvent.PROVIDER_CONFIGURATION_CHANGED + assert details.flags_changed == ["checkout"] + client.flag_tracker.remove_flag_change_notifier.assert_called_once_with(listener) + client.stop.assert_not_called() + + +def test_track_maps_numeric_value_and_context(): + client = make_client() + provider = FeatBitProvider(client) + + provider.track( + "checkout", + make_context(), + TrackingEventDetails(value=2.5, attributes={"currency": "CNY"}), + ) + + client.track_metric.assert_called_once_with( + {"key": "user-123", "name": "Alice", "plan": "beta"}, + "checkout", + 2.5, + ) + + +def test_track_without_context_or_on_error_never_raises(): + client = make_client() + provider = FeatBitProvider(client) + + provider.track("missing-context") + client.track_metric.side_effect = RuntimeError("boom") + provider.track("failure", make_context()) + + assert client.track_metric.call_count == 1 + + +def test_concurrent_resolutions_do_not_share_request_state(): + client = make_client() + + def evaluate(_flag_key, user, _default): + return EvalDetail( + REASON_TARGET_MATCH, + user["request"], + "flag-key", + "Flag", + "variation-id", + ) + + client.variation_detail.side_effect = evaluate + provider = FeatBitProvider(client) + + def resolve(index): + context = EvaluationContext( + f"user-{index}", {"name": f"User {index}", "request": str(index)} + ) + return provider.resolve_string_details("flag-key", "fallback", context).value + + with ThreadPoolExecutor(max_workers=16) as executor: + results = list(executor.map(resolve, range(1000))) + + assert results == [str(index) for index in range(1000)] From e344b51ee93f6bd56ae7d8c2c21eb126e00281d8 Mon Sep 17 00:00:00 2001 From: WuJiayi0307 Date: Wed, 22 Jul 2026 22:50:29 +0800 Subject: [PATCH 2/5] docs: add packaging and usage metadata --- README.md | 7 +++++++ pyproject.toml | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 173ce4a..bff8dc6 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ flag synchronization, local evaluation, and insight events remain owned by ## Installation +Once the provider and the required FeatBit SDK version are published: + ```shell pip install featbit-openfeature-server ``` @@ -72,3 +74,8 @@ than represented inaccurately. Unit tests use mocks, and the integration test uses FeatBit offline data. Neither requires a FeatBit account. + +```shell +python -m pytest -q +python -m flake8 featbit_openfeature tests +``` diff --git a/pyproject.toml b/pyproject.toml index 930b8f5..4488d64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,8 +16,8 @@ dependencies = [ ] [project.urls] -Homepage = "https://github.com/featbit/featbit-python-sdk" -Issues = "https://github.com/featbit/featbit-python-sdk/issues" +Homepage = "https://github.com/featbit/openfeature-provider-python-server" +Issues = "https://github.com/featbit/openfeature-provider-python-server/issues" [tool.setuptools.packages.find] include = ["featbit_openfeature*"] From baf692869793a877d8ad45711260b5742050d5a2 Mon Sep 17 00:00:00 2001 From: WuJiayi0307 Date: Wed, 22 Jul 2026 22:52:59 +0800 Subject: [PATCH 3/5] docs: record implementation and verification results --- IMPLEMENTATION_REPORT.md | 73 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 IMPLEMENTATION_REPORT.md diff --git a/IMPLEMENTATION_REPORT.md b/IMPLEMENTATION_REPORT.md new file mode 100644 index 0000000..0b2aadf --- /dev/null +++ b/IMPLEMENTATION_REPORT.md @@ -0,0 +1,73 @@ +# FeatBit OpenFeature Python Provider 实施报告 + +日期:2026-07-22 + +## 已完成 + +### 1. FeatBit Python SDK 前置改动 + +- GitHub fork: +- 分支:`feat/openfeature-variation-id` +- 提交:`b10fc9b feat: expose variation id in evaluation details` +- 远端分支: + +改动内容: + +- `EvalDetail` 增加向后兼容的可选 `variation_id` 属性。 +- `_EvalResult.to_evail_detail` 在成功评估时透传内部 variation ID。 +- `client not ready`、`flag not found` 等错误结果返回 `variation_id=None`。 +- 旧四参数 `EvalDetail` 构造方式保持兼容。 + +验证结果:SDK 全量 `55 passed`,相关文件 flake8 通过。 + +### 2. OpenFeature Provider + +三个核心实现文件: + +- `featbit_openfeature/impl/context_converter.py` +- `featbit_openfeature/impl/details_converter.py` +- `featbit_openfeature/provider.py` + +已实现: + +- 注入并复用现有长生命周期 `FBClient`。 +- boolean、string、integer、float、object 五类解析接口。 +- OpenFeature EvaluationContext 到 FeatBit user dict 的转换。 +- FeatBit reason/error 到 OpenFeature Reason/ErrorCode 的转换。 +- SDK variation ID 到 OpenFeature variant 的转换。 +- 初始化等待和 OpenFeature 生命周期错误。 +- `PROVIDER_CONFIGURATION_CHANGED` 事件桥接。 +- OpenFeature `track()` 到 `FBClient.track_metric()` 的数值事件桥接。 +- 评估和 tracking 边界异常保护。 +- Provider shutdown 幂等且不擅自关闭调用方持有的 `FBClient`。 + +### 3. 测试与构建 + +Provider 测试结果:`48 passed`。 + +覆盖范围: + +- Context 字段、后备 key、datetime、嵌套属性策略。 +- 全部 reason/error 映射和 variant 规则。 +- 五类解析及类型不匹配。 +- 无 context、底层异常和安全默认值。 +- READY、超时、FATAL 初始化路径。 +- 配置变化事件、shutdown 和 tracking。 +- 16 工作线程、1000 次并发解析。 +- 使用真实 `FBClient`、真实 FeatBit Evaluator 和离线旗标数据,从 OpenFeature Client 入口完成端到端评估。 + +构建产物:`featbit_openfeature_server-0.1.0-py3-none-any.whl`。 + +SHA-256:`DC7B5C28FC039C0C15D4147E0F76808C10738A4F49A5445AE50C087916D2918D` + +## 尚未执行的外部步骤 + +以下步骤不是本地实现或单元测试的阻塞项: + +1. 把 SDK 分支向 `featbit/featbit-python-sdk` 提交 PR。 +2. 确定并创建正式 Provider 仓库,建议为 `featbit/openfeature-provider-python-server`。 +3. 合并 SDK 改动并发布包含 `variation_id` 的版本;Provider 当前按计划依赖 `fb-python-sdk>=1.1.8`,实际版本号应随发布调整。 +4. 发布 Provider 到 PyPI。 +5. 使用 FeatBit Cloud 的 Server-Side environment secret、Streaming URL 和 Event URL 做一次 smoke test。 + +本轮环境中没有注入上述 Cloud 配置,因此没有读取、复制或写入环境密钥。Cloud smoke test 应验证:Provider READY、OpenFeature 与直接 SDK 返回一致、控制台热更新无需重启、评估事件成功上传。 From b9396b5d74a24a6f004112860d8a1de5abd2912c Mon Sep 17 00:00:00 2001 From: WuJiayi0307 Date: Thu, 23 Jul 2026 22:31:47 +0800 Subject: [PATCH 4/5] feat: finalize standalone OpenFeature provider --- .github/workflows/ci.yml | 36 ++++++++++ IMPLEMENTATION_REPORT.md | 73 -------------------- MANIFEST.in | 4 ++ README.md | 35 ++++++++-- featbit_openfeature/provider.py | 106 ++++++++++++++++++++++-------- pyproject.toml | 13 +++- tests/test_offline_integration.py | 11 ++++ tests/test_provider.py | 72 ++++++++++++++++++++ 8 files changed, 243 insertions(+), 107 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 IMPLEMENTATION_REPORT.md create mode 100644 MANIFEST.in diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..addede2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install + run: | + python -m pip install --upgrade pip + python -m pip install "openfeature-sdk>=0.10,<1" "build>=1,<2" "flake8>=7,<8" "pytest>=8,<10" + python -m pip install "fb-python-sdk @ git+https://github.com/featbit/featbit-python-sdk.git@refs/pull/14/head" + python -m pip install -e . --no-deps + - name: Lint + run: python -m flake8 featbit_openfeature tests + - name: Test + run: python -m pytest -q + - name: Build + if: matrix.python-version == '3.12' + run: python -m build diff --git a/IMPLEMENTATION_REPORT.md b/IMPLEMENTATION_REPORT.md deleted file mode 100644 index 0b2aadf..0000000 --- a/IMPLEMENTATION_REPORT.md +++ /dev/null @@ -1,73 +0,0 @@ -# FeatBit OpenFeature Python Provider 实施报告 - -日期:2026-07-22 - -## 已完成 - -### 1. FeatBit Python SDK 前置改动 - -- GitHub fork: -- 分支:`feat/openfeature-variation-id` -- 提交:`b10fc9b feat: expose variation id in evaluation details` -- 远端分支: - -改动内容: - -- `EvalDetail` 增加向后兼容的可选 `variation_id` 属性。 -- `_EvalResult.to_evail_detail` 在成功评估时透传内部 variation ID。 -- `client not ready`、`flag not found` 等错误结果返回 `variation_id=None`。 -- 旧四参数 `EvalDetail` 构造方式保持兼容。 - -验证结果:SDK 全量 `55 passed`,相关文件 flake8 通过。 - -### 2. OpenFeature Provider - -三个核心实现文件: - -- `featbit_openfeature/impl/context_converter.py` -- `featbit_openfeature/impl/details_converter.py` -- `featbit_openfeature/provider.py` - -已实现: - -- 注入并复用现有长生命周期 `FBClient`。 -- boolean、string、integer、float、object 五类解析接口。 -- OpenFeature EvaluationContext 到 FeatBit user dict 的转换。 -- FeatBit reason/error 到 OpenFeature Reason/ErrorCode 的转换。 -- SDK variation ID 到 OpenFeature variant 的转换。 -- 初始化等待和 OpenFeature 生命周期错误。 -- `PROVIDER_CONFIGURATION_CHANGED` 事件桥接。 -- OpenFeature `track()` 到 `FBClient.track_metric()` 的数值事件桥接。 -- 评估和 tracking 边界异常保护。 -- Provider shutdown 幂等且不擅自关闭调用方持有的 `FBClient`。 - -### 3. 测试与构建 - -Provider 测试结果:`48 passed`。 - -覆盖范围: - -- Context 字段、后备 key、datetime、嵌套属性策略。 -- 全部 reason/error 映射和 variant 规则。 -- 五类解析及类型不匹配。 -- 无 context、底层异常和安全默认值。 -- READY、超时、FATAL 初始化路径。 -- 配置变化事件、shutdown 和 tracking。 -- 16 工作线程、1000 次并发解析。 -- 使用真实 `FBClient`、真实 FeatBit Evaluator 和离线旗标数据,从 OpenFeature Client 入口完成端到端评估。 - -构建产物:`featbit_openfeature_server-0.1.0-py3-none-any.whl`。 - -SHA-256:`DC7B5C28FC039C0C15D4147E0F76808C10738A4F49A5445AE50C087916D2918D` - -## 尚未执行的外部步骤 - -以下步骤不是本地实现或单元测试的阻塞项: - -1. 把 SDK 分支向 `featbit/featbit-python-sdk` 提交 PR。 -2. 确定并创建正式 Provider 仓库,建议为 `featbit/openfeature-provider-python-server`。 -3. 合并 SDK 改动并发布包含 `variation_id` 的版本;Provider 当前按计划依赖 `fb-python-sdk>=1.1.8`,实际版本号应随发布调整。 -4. 发布 Provider 到 PyPI。 -5. 使用 FeatBit Cloud 的 Server-Side environment secret、Streaming URL 和 Event URL 做一次 smoke test。 - -本轮环境中没有注入上述 Cloud 配置,因此没有读取、复制或写入环境密钥。Cloud smoke test 应验证:Provider READY、OpenFeature 与直接 SDK 返回一致、控制台热更新无需重启、评估事件成功上传。 diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..b27661b --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include LICENSE +include README.md +recursive-include tests *.json +recursive-include tests *.py diff --git a/README.md b/README.md index bff8dc6..edf348b 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,12 @@ flag synchronization, local evaluation, and insight events remain owned by ## Installation -Once the provider and the required FeatBit SDK version are published: - ```shell pip install featbit-openfeature-server ``` -The initial implementation requires a FeatBit Python SDK release that exposes -`EvalDetail.variation_id` (planned as `fb-python-sdk>=1.1.8`). +The provider requires `fb-python-sdk>=1.1.8`, which exposes variation IDs and +data-update status listeners. ## Usage @@ -64,12 +62,40 @@ afterward so queued events are flushed and background resources are released. - FeatBit reasons and failures to OpenFeature `Reason` and `ErrorCode`. - FeatBit variation ID to OpenFeature `variant`. - FeatBit flag changes to `PROVIDER_CONFIGURATION_CHANGED`. +- FeatBit runtime data-source status to OpenFeature provider status. - OpenFeature tracking value to `FBClient.track_metric()`. Structured evaluation-context attributes and tracking custom attributes are not supported by the current FeatBit user and metric schemas. They are ignored rather than represented inaccurately. +## Provider status + +Applications can read the status through the standard OpenFeature client: + +```python +from openfeature.provider import ProviderStatus + +status = client.get_provider_status() +assert status in { + ProviderStatus.NOT_READY, + ProviderStatus.READY, + ProviderStatus.STALE, + ProviderStatus.ERROR, + ProviderStatus.FATAL, +} +``` + +FeatBit states are mapped as follows: + +| FeatBit state | OpenFeature status | +| --- | --- | +| initializing | `NOT_READY` during provider initialization | +| OK | `READY` | +| interrupted | `STALE` | +| permanently off | `FATAL` | +| provider shutdown | `NOT_READY` | + ## Development tests Unit tests use mocks, and the integration test uses FeatBit offline data. Neither @@ -78,4 +104,5 @@ requires a FeatBit account. ```shell python -m pytest -q python -m flake8 featbit_openfeature tests +python -m build ``` diff --git a/featbit_openfeature/provider.py b/featbit_openfeature/provider.py index 8cb479a..3950ec7 100644 --- a/featbit_openfeature/provider.py +++ b/featbit_openfeature/provider.py @@ -7,7 +7,7 @@ from fbclient.client import FBClient from fbclient.flag_change_notification import FlagChangedListener, FlagChangedNotice -from fbclient.status_types import StateType +from fbclient.status_types import State, StateType from openfeature.evaluation_context import EvaluationContext from openfeature.event import ProviderEventDetails from openfeature.exception import ( @@ -44,7 +44,12 @@ def on_flag_change(self, notice: FlagChangedNotice): class FeatBitProvider(AbstractProvider): - """OpenFeature provider backed by an existing FeatBit ``FBClient``.""" + """OpenFeature provider backed by an existing thread-safe ``FBClient``. + + The provider does not own the client. ``shutdown`` removes the listeners + installed by the provider, but the application remains responsible for + calling ``FBClient.stop`` when it no longer needs the SDK. + """ def __init__(self, client: FBClient, initialization_timeout: float = 15.0): super().__init__() @@ -59,10 +64,11 @@ def __init__(self, client: FBClient, initialization_timeout: float = 15.0): self._details_converter = ResolutionDetailsConverter() self._lifecycle_lock = threading.Lock() self._flag_change_listener: _FlagChangeListener | None = None + self._status_listener_registered = False @property def client(self) -> FBClient: - """Returns the FeatBit client supplied to this provider.""" + """Return the FeatBit client supplied to this provider.""" return self._client def initialize(self, evaluation_context: EvaluationContext) -> None: @@ -77,19 +83,33 @@ def initialize(self, evaluation_context: EvaluationContext) -> None: raise ProviderFatalError(message) raise ProviderNotReadyError(message) - self._register_flag_change_listener() + self._register_listeners() def shutdown(self) -> None: with self._lifecycle_lock: - listener = self._flag_change_listener - self._flag_change_listener = None + flag_listener = self._flag_change_listener + status_listener_registered = self._status_listener_registered - if listener is None: - return - try: - self._client.flag_tracker.remove_flag_change_notifier(listener) - except Exception: - logger.exception("Failed to remove FeatBit flag change listener") + if flag_listener is not None: + try: + self._client.flag_tracker.remove_flag_change_notifier(flag_listener) + except Exception: + logger.exception("Failed to remove FeatBit flag change listener") + else: + with self._lifecycle_lock: + if self._flag_change_listener is flag_listener: + self._flag_change_listener = None + + if status_listener_registered: + try: + self._client.update_status_provider.remove_listener( + self._handle_data_update_status + ) + except Exception: + logger.exception("Failed to remove FeatBit status listener") + else: + with self._lifecycle_lock: + self._status_listener_registered = False def get_metadata(self) -> Metadata: return Metadata("featbit-openfeature-server") @@ -158,7 +178,8 @@ def track( if tracking_event_details is not None: if tracking_event_details.attributes: logger.debug( - "FeatBit track_metric does not support tracking attributes; ignoring them" + "FeatBit track_metric does not support tracking attributes; " + "ignoring them" ) if tracking_event_details.value is not None: metric_value = float(tracking_event_details.value) @@ -233,25 +254,56 @@ def _type_mismatch_details(default_value: Any) -> FlagResolutionDetails[Any]: error_message="FeatBit flag value does not match the requested type", ) - def _register_flag_change_listener(self) -> None: + def _register_listeners(self) -> None: with self._lifecycle_lock: - if self._flag_change_listener is not None: - return - listener = _FlagChangeListener(self._emit_configuration_changed) - try: - self._client.flag_tracker.add_flag_changed_listener(listener) - except Exception: - logger.exception("Failed to register FeatBit flag change listener") - return - self._flag_change_listener = listener + if not self._status_listener_registered: + try: + self._client.update_status_provider.add_listener( + self._handle_data_update_status + ) + except Exception: + logger.exception("Failed to register FeatBit status listener") + else: + self._status_listener_registered = True + + if self._flag_change_listener is None: + listener = _FlagChangeListener(self._emit_configuration_changed) + try: + self._client.flag_tracker.add_flag_changed_listener(listener) + except Exception: + logger.exception("Failed to register FeatBit flag change listener") + else: + self._flag_change_listener = listener def _emit_configuration_changed(self, flag_key: str) -> None: - self.emit_provider_configuration_changed( - ProviderEventDetails(flags_changed=[flag_key]) - ) + try: + self.emit_provider_configuration_changed( + ProviderEventDetails(flags_changed=[flag_key]) + ) + except Exception: + logger.exception("Failed to emit OpenFeature configuration change event") + + def _handle_data_update_status(self, state: State) -> None: + try: + if state.state_type == StateType.OK: + self.emit_provider_ready(ProviderEventDetails()) + elif state.state_type == StateType.INTERRUPTED: + self.emit_provider_stale( + ProviderEventDetails(message=self._state_message(state)) + ) + elif state.state_type == StateType.OFF: + self.emit_provider_error( + ProviderEventDetails( + error_code=ErrorCode.PROVIDER_FATAL, + message=self._state_message(state), + ) + ) + except Exception: + logger.exception("Failed to emit OpenFeature provider status event") @staticmethod def _state_message(state: Any) -> str: error = getattr(state, "error_track", None) message = getattr(error, "message", None) - return message or "FeatBit client initialization did not complete" + state_type = getattr(getattr(state, "state_type", None), "name", "UNKNOWN") + return message or f"FeatBit data source state is {state_type}" diff --git a/pyproject.toml b/pyproject.toml index 4488d64..13bb23e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=65"] +requires = ["setuptools>=77", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -7,14 +7,21 @@ name = "featbit-openfeature-server" version = "0.1.0" description = "OpenFeature provider for the FeatBit Python server SDK" readme = "README.md" -requires-python = ">=3.10" -license = {text = "MIT"} +requires-python = ">=3.10,<3.13" +license = "MIT" authors = [{name = "FeatBit"}] dependencies = [ "openfeature-sdk>=0.10,<1", "fb-python-sdk>=1.1.8,<2", ] +[project.optional-dependencies] +dev = [ + "build>=1,<2", + "flake8>=7,<8", + "pytest>=8,<10", +] + [project.urls] Homepage = "https://github.com/featbit/openfeature-provider-python-server" Issues = "https://github.com/featbit/openfeature-provider-python-server/issues" diff --git a/tests/test_offline_integration.py b/tests/test_offline_integration.py index 56bceb2..9aa0dae 100644 --- a/tests/test_offline_integration.py +++ b/tests/test_offline_integration.py @@ -2,9 +2,11 @@ from fbclient.client import FBClient from fbclient.config import Config +from fbclient.status_types import State from openfeature import api from openfeature.evaluation_context import EvaluationContext from openfeature.flag_evaluation import Reason +from openfeature.provider import ProviderStatus from featbit_openfeature import FeatBitProvider @@ -33,6 +35,15 @@ def test_openfeature_client_evaluates_with_real_featbit_sdk_offline(): assert details.reason == Reason.TARGETING_MATCH assert details.variant == "variation-v2" assert details.error_code is None + + fb_client.update_status_provider.update_state(State.ok_state()) + fb_client.update_status_provider.update_state( + State.interrupted_state("network", "connection lost") + ) + assert client.get_provider_status() == ProviderStatus.STALE + + fb_client.update_status_provider.update_state(State.ok_state()) + assert client.get_provider_status() == ProviderStatus.READY finally: api.shutdown() fb_client.stop() diff --git a/tests/test_provider.py b/tests/test_provider.py index d0614e7..07158b2 100644 --- a/tests/test_provider.py +++ b/tests/test_provider.py @@ -6,6 +6,7 @@ from fbclient.evaluator import REASON_CLIENT_NOT_READY, REASON_TARGET_MATCH from fbclient.flag_change_notification import FlagChangedNotice from fbclient.status_types import State +from openfeature import api from openfeature.evaluation_context import EvaluationContext from openfeature.event import ProviderEvent from openfeature.exception import ( @@ -14,6 +15,7 @@ ProviderNotReadyError, ) from openfeature.flag_evaluation import Reason +from openfeature.provider import ProviderStatus from openfeature.track import TrackingEventDetails from featbit_openfeature import FeatBitProvider @@ -202,9 +204,79 @@ def test_flag_change_is_emitted_and_shutdown_removes_listener(): assert event == ProviderEvent.PROVIDER_CONFIGURATION_CHANGED assert details.flags_changed == ["checkout"] client.flag_tracker.remove_flag_change_notifier.assert_called_once_with(listener) + client.update_status_provider.remove_listener.assert_called_once_with( + provider._handle_data_update_status + ) client.stop.assert_not_called() +def test_flag_change_callback_exception_never_escapes(): + client = make_client() + provider = FeatBitProvider(client) + emitter = Mock(side_effect=RuntimeError("boom")) + provider.attach(emitter) + provider.initialize(EvaluationContext()) + listener = client.flag_tracker.add_flag_changed_listener.call_args.args[0] + + listener.on_flag_change(FlagChangedNotice("checkout")) + + emitter.assert_called_once() + + +def test_shutdown_retries_listener_removal_after_failure(): + client = make_client() + provider = FeatBitProvider(client) + provider.initialize(EvaluationContext()) + listener = client.flag_tracker.add_flag_changed_listener.call_args.args[0] + client.flag_tracker.remove_flag_change_notifier.side_effect = [ + RuntimeError("boom"), + None, + ] + + provider.shutdown() + provider.initialize(EvaluationContext()) + provider.shutdown() + + client.flag_tracker.add_flag_changed_listener.assert_called_once_with(listener) + assert client.flag_tracker.remove_flag_change_notifier.call_count == 2 + + +def test_runtime_featbit_status_updates_openfeature_provider_status(): + client = make_client() + provider = FeatBitProvider(client) + + try: + api.set_provider_and_wait(provider) + openfeature_client = api.get_client() + listener = client.update_status_provider.add_listener.call_args.args[0] + + assert openfeature_client.get_provider_status() == ProviderStatus.READY + + listener(State.interrupted_state("network", "connection lost")) + assert openfeature_client.get_provider_status() == ProviderStatus.STALE + + listener(State.ok_state()) + assert openfeature_client.get_provider_status() == ProviderStatus.READY + + listener(State.error_off_state("credential", "invalid secret")) + assert openfeature_client.get_provider_status() == ProviderStatus.FATAL + finally: + api.shutdown() + + +def test_status_callback_exception_never_escapes(): + client = make_client() + provider = FeatBitProvider(client) + emitter = Mock(side_effect=RuntimeError("boom")) + provider.attach(emitter) + + provider._handle_data_update_status( + State.interrupted_state("network", "connection lost") + ) + + emitter.assert_called_once() + + def test_track_maps_numeric_value_and_context(): client = make_client() provider = FeatBitProvider(client) From cd342539580423e375adcd79545516bc301f4dd7 Mon Sep 17 00:00:00 2001 From: WuJiayi0307 Date: Fri, 24 Jul 2026 19:12:30 +0800 Subject: [PATCH 5/5] fix: align package license with repository --- pyproject.toml | 2 +- tests/test_packaging_metadata.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 tests/test_packaging_metadata.py diff --git a/pyproject.toml b/pyproject.toml index 13bb23e..c862021 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ version = "0.1.0" description = "OpenFeature provider for the FeatBit Python server SDK" readme = "README.md" requires-python = ">=3.10,<3.13" -license = "MIT" +license = "Apache-2.0" authors = [{name = "FeatBit"}] dependencies = [ "openfeature-sdk>=0.10,<1", diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py new file mode 100644 index 0000000..957b082 --- /dev/null +++ b/tests/test_packaging_metadata.py @@ -0,0 +1,13 @@ +from pathlib import Path + + +ROOT = Path(__file__).parent.parent + + +def test_project_license_matches_repository_license(): + pyproject = ROOT.joinpath("pyproject.toml").read_text(encoding="utf-8") + license_text = ROOT.joinpath("LICENSE").read_text(encoding="utf-8") + + assert 'license = "Apache-2.0"' in pyproject + assert "Apache License" in license_text + assert "Version 2.0" in license_text