From b10fc9b2b8e3e6552ec49f98aab14926805a841d Mon Sep 17 00:00:00 2001 From: WuJiayi0307 Date: Wed, 22 Jul 2026 22:47:36 +0800 Subject: [PATCH 1/5] feat: expose variation id in evaluation details --- fbclient/common_types.py | 15 ++++++++++++--- tests/test_fbclient.py | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/fbclient/common_types.py b/fbclient/common_types.py index 9539f3b..c282201 100644 --- a/fbclient/common_types.py +++ b/fbclient/common_types.py @@ -90,19 +90,21 @@ def __init__(self, reason: str, variation: Any, key_name: Optional[str] = None, - name: Optional[str] = None): + name: Optional[str] = None, + variation_id: Optional[str] = None): """Constructs an instance. - :param id: variation id :param reason: main factor that influenced the flag evaluation value :param variation: result of the flag evaluation in any type of string, bool, float/int, json(Python object) or default value if flag evaluation fails :param key_name: key name of the flag :param name: name of the flag + :param variation_id: stable identifier of the resolved variation, or None if evaluation failed """ self._reason = reason self._variation = variation self._key_name = key_name self._name = name + self._variation_id = variation_id @property def reason(self) -> str: @@ -129,6 +131,12 @@ def name(self) -> Optional[str]: """ return self._name + @property + def variation_id(self) -> Optional[str]: + """The stable identifier of the resolved variation, if evaluation succeeded. + """ + return self._variation_id + def to_json_dict(self) -> dict: json_dict = {} json_dict['reason'] = self.reason @@ -329,7 +337,8 @@ def is_success(self) -> bool: @property def to_evail_detail(self) -> "EvalDetail": _value = cast_variation_by_flag_type(self.__flag_type, self.__value) - return EvalDetail(self.__reason, _value, self.__key_name, self.__name) + variation_id = self.__id if self.is_success else None + return EvalDetail(self.__reason, _value, self.__key_name, self.__name, variation_id) @property def to_flag_state(self) -> "FlagState": diff --git a/tests/test_fbclient.py b/tests/test_fbclient.py index 5ff30fc..c8e9373 100644 --- a/tests/test_fbclient.py +++ b/tests/test_fbclient.py @@ -6,6 +6,7 @@ import pytest from fbclient.client import FBClient +from fbclient.common_types import EvalDetail from fbclient.config import Config from fbclient.data_storage import InMemoryDataStorage from fbclient.evaluator import (REASON_CLIENT_NOT_READY, REASON_ERROR, @@ -28,6 +29,16 @@ USER_EMAIL = {"key": "test-user-7@featbit.com", "name": "test-user-7"} +def test_eval_detail_variation_id_is_backward_compatible(): + legacy_detail = EvalDetail("test reason", True, "flag-key", "Flag name") + assert legacy_detail.variation_id is None + + detail_with_id = EvalDetail( + "test reason", True, "flag-key", "Flag name", "variation-id" + ) + assert detail_with_id.variation_id == "variation-id" + + def make_fb_client(update_processor_imp, event_processor_imp, start_wait=15.): config = Config(FAKE_ENV_SECRET, event_url=FAKE_URL, @@ -118,6 +129,7 @@ def start(): detail = client.variation_detail("ff-test-bool", USER_1, False) assert detail.variation is False assert detail.reason == REASON_CLIENT_NOT_READY + assert detail.variation_id is None all_states = client.get_all_latest_flag_variations(USER_1) # type: ignore assert not all_states.success assert all_states.reason == REASON_CLIENT_NOT_READY @@ -133,6 +145,7 @@ def test_bool_variation(): detail = client.variation_detail("ff-test-bool", USER_2, False) assert detail.variation is True assert detail.reason == REASON_TARGET_MATCH + assert detail.variation_id == "18b369f8-453f-46d7-88cc-fe41d29ca6e3" assert client.variation("ff-test-bool", USER_3, False) is False detail = client.variation_detail("ff-test-bool", USER_4, False) assert detail.variation is True @@ -238,6 +251,7 @@ def test_variation_argument_error(): detail = client.variation_detail("ff-not-existed", USER_1, False) assert detail.variation is False assert detail.reason == REASON_FLAG_NOT_FOUND + assert detail.variation_id is None detail = client.variation_detail("ff-test-bool", None, None) # type: ignore assert detail.variation is None assert detail.reason == REASON_USER_NOT_SPECIFIED From e09df9ddfc8d279530e82b25944dc91e6c57a75f Mon Sep 17 00:00:00 2001 From: WuJiayi0307 Date: Thu, 23 Jul 2026 00:16:16 +0800 Subject: [PATCH 2/5] feat: integrate OpenFeature server provider --- .github/workflows/code-quality.yaml | 3 + MANIFEST.in | 4 +- README.md | 19 +- docs/openfeature.md | 103 ++++++ fbclient/__init__.py | 3 - featbit_openfeature/__init__.py | 5 + featbit_openfeature/impl/__init__.py | 1 + featbit_openfeature/impl/context_converter.py | 75 +++++ featbit_openfeature/impl/details_converter.py | 57 ++++ featbit_openfeature/provider.py | 263 ++++++++++++++++ featbit_openfeature/py.typed | 1 + release/description.md | 9 +- release/package.json | 4 +- setup.py | 5 +- tests/openfeature/__init__.py | 0 tests/openfeature/fixtures/bootstrap.json | 41 +++ tests/openfeature/test_context_converter.py | 101 ++++++ tests/openfeature/test_details_converter.py | 75 +++++ tests/openfeature/test_offline_integration.py | 44 +++ tests/openfeature/test_provider.py | 292 ++++++++++++++++++ 20 files changed, 1094 insertions(+), 11 deletions(-) create mode 100644 docs/openfeature.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 tests/openfeature/__init__.py create mode 100644 tests/openfeature/fixtures/bootstrap.json create mode 100644 tests/openfeature/test_context_converter.py create mode 100644 tests/openfeature/test_details_converter.py create mode 100644 tests/openfeature/test_offline_integration.py create mode 100644 tests/openfeature/test_provider.py diff --git a/.github/workflows/code-quality.yaml b/.github/workflows/code-quality.yaml index 4bbc780..a1d8417 100644 --- a/.github/workflows/code-quality.yaml +++ b/.github/workflows/code-quality.yaml @@ -30,6 +30,9 @@ jobs: python -m pip install --upgrade pip pip install flake8 pytest pytest-mock if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Install OpenFeature dependency + if: ${{ matrix.python-version == '3.10' || matrix.python-version == '3.11' || matrix.python-version == '3.12' }} + run: pip install "openfeature-sdk>=0.10,<1" - name: Lint with flake8 run: | # stop the build if there are Python syntax errors diff --git a/MANIFEST.in b/MANIFEST.in index 971366d..b967af8 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,6 @@ include requirements.txt include README.md include dev-requirements.txt -include release/package.json \ No newline at end of file +include release/package.json +include featbit_openfeature/py.typed +recursive-include docs *.md diff --git a/README.md b/README.md index 5e79b3f..17039cf 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ If you want to use your own data source, see [Offline Mode](#offline-mode). ## Get Started ### Installation -install the sdk in using pip, this version of the SDK is compatible with Python 3.6 through 3.11. +install the sdk in using pip, this version of the SDK is compatible with Python 3.6 through 3.12. ```shell pip install fb-python-sdk @@ -227,6 +227,23 @@ client.track_metric(user, event_name, numeric_value); Make sure `track_metric` is called after the related feature flag is evaluated by simply calling `variation` or `variation_detail` otherwise, the custom event may not be included into the experiment result. +## OpenFeature + +This package includes an optional server-side OpenFeature provider for Python +3.10 and later. Install it with: + +```shell +pip install "fb-python-sdk[openfeature]" +``` + +The provider wraps the existing `FBClient`, implements all five typed +OpenFeature resolution methods, exposes the FeatBit variation ID as the +OpenFeature variant, forwards tracking and configuration-change events, and +returns safe fallback details when evaluation fails. + +See [the OpenFeature provider guide](docs/openfeature.md) for setup, lifecycle, +context conversion, reason/error mapping, and a complete example. + ## Getting support - If you have a specific question about using this sdk, we encourage you diff --git a/docs/openfeature.md b/docs/openfeature.md new file mode 100644 index 0000000..f348fa1 --- /dev/null +++ b/docs/openfeature.md @@ -0,0 +1,103 @@ +# FeatBit OpenFeature provider for Python + +The FeatBit Python SDK includes an optional server-side OpenFeature provider. +It translates the OpenFeature API to the existing, thread-safe `FBClient` and +does not replace FeatBit's WebSocket synchronization, evaluator, event +processor, logging, or storage implementations. + +## Compatibility + +- FeatBit's base SDK keeps its existing Python 3.6–3.12 compatibility range. +- The OpenFeature integration requires Python 3.10 or later because + `openfeature-sdk` 0.10 requires Python 3.10 or later. +- The provider accepts an existing `FBClient`. The application owns that + client and must call `stop()` during application shutdown. + +## Installation + +```shell +pip install "fb-python-sdk[openfeature]" +``` + +## Usage + +```python +from fbclient.client import FBClient +from fbclient.config import Config +from featbit_openfeature import FeatBitProvider +from openfeature import api +from openfeature.evaluation_context import EvaluationContext + +fb_client = FBClient( + Config( + env_secret="your-environment-secret", + event_url="https://app-evaluation.featbit.co", + streaming_url="wss://app-evaluation.featbit.co", + ), + start_wait=0, +) + +api.set_provider_and_wait(FeatBitProvider(fb_client)) +client = api.get_client() + +details = client.get_string_details( + "python-app-release", + "v1", + EvaluationContext( + targeting_key="user-123", + attributes={"name": "Alice", "releaseRing": "canary"}, + ), +) + +print(details.value, details.variant, details.reason) + +# Application shutdown +api.shutdown() +fb_client.stop() +``` + +Use the endpoint values shown on the FeatBit environment's SDK connection +page. Self-hosted installations normally use their own event and streaming +URLs. + +## Context mapping + +| OpenFeature field | FeatBit user field | Behavior | +| --- | --- | --- | +| `targeting_key` | `key` | Required; non-empty `attributes["key"]` is a compatibility fallback. | +| `attributes["name"]` | `name` | Falls back to the targeting key. | +| string, boolean, integer, float | customized property | Preserved. | +| `datetime` | customized property | Converted to ISO 8601; naive values use UTC. | +| list, map, or other structured value | — | Ignored because `FBUser` only retains scalar customized properties. | + +## Evaluation mapping + +The provider implements all five typed OpenFeature resolution methods: +boolean, string, integer, float, and object. Successful evaluations expose +FeatBit's variation ID as OpenFeature's `variant`. + +| FeatBit reason | OpenFeature result | +| --- | --- | +| `flag off` | `DISABLED` | +| `target match`, `rule match` | `TARGETING_MATCH` | +| `client not ready` | `ERROR` / `PROVIDER_NOT_READY` | +| `flag not found` | `ERROR` / `FLAG_NOT_FOUND` | +| `user not specified` | `ERROR` / `TARGETING_KEY_MISSING` | +| `wrong type` | `ERROR` / `TYPE_MISMATCH` | +| unexpected exception | fallback value / `GENERAL` | + +All typed resolution methods catch unexpected SDK or conversion errors and +return the caller's default value with OpenFeature error details. They do not +propagate those exceptions into application evaluation code. OpenFeature +provider initialization may still report `ProviderNotReadyError` or +`ProviderFatalError`, as required by the OpenFeature lifecycle. + +## Provider events and tracking + +- FeatBit flag-change notifications are emitted as + `PROVIDER_CONFIGURATION_CHANGED` events. +- `track()` maps to `FBClient.track_metric()`; numeric tracking values are + preserved. OpenFeature tracking attributes are ignored because FeatBit's + current metric API does not accept them. +- `shutdown()` is idempotent and removes only the provider's flag-change + listener. It does not stop the injected `FBClient`. diff --git a/fbclient/__init__.py b/fbclient/__init__.py index 054a925..89c9c70 100644 --- a/fbclient/__init__.py +++ b/fbclient/__init__.py @@ -24,9 +24,7 @@ def get() -> FBClient: If you need to create multiple client instances with different environments, instead of this singleton approach you can call directly the :class:`fbclient.client.FBClient` constructor. """ - global __config global __client - global __lock try: __lock.read_lock() @@ -58,7 +56,6 @@ def set_config(config: Config): """ global __config global __client - global __lock try: __lock.write_lock() diff --git a/featbit_openfeature/__init__.py b/featbit_openfeature/__init__.py new file mode 100644 index 0000000..c3e71e0 --- /dev/null +++ b/featbit_openfeature/__init__.py @@ -0,0 +1,5 @@ +"""OpenFeature provider backed by the FeatBit Python server SDK.""" + +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..eae6109 --- /dev/null +++ b/featbit_openfeature/impl/__init__.py @@ -0,0 +1 @@ +"""Internal conversion helpers for 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..65ee6a1 --- /dev/null +++ b/featbit_openfeature/impl/context_converter.py @@ -0,0 +1,75 @@ +import logging +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import TargetingKeyMissingError + + +logger = logging.getLogger("featbit-openfeature-server") + +_RESERVED_ATTRIBUTES = {"key", "keyid", "targetingKey", "name"} +_UNSUPPORTED_ATTRIBUTE = object() + + +class EvaluationContextConverter: + """Convert an OpenFeature evaluation context to a FeatBit user mapping.""" + + def to_fb_user(self, context: Optional[EvaluationContext]) -> 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 = {"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 + + 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 diff --git a/featbit_openfeature/impl/details_converter.py b/featbit_openfeature/impl/details_converter.py new file mode 100644 index 0000000..1044422 --- /dev/null +++ b/featbit_openfeature/impl/details_converter.py @@ -0,0 +1,57 @@ +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: + """Convert FeatBit details to OpenFeature resolution details.""" + + def to_resolution_details( + self, result: EvalDetail, resolved_value: Any + ) -> FlagResolutionDetails: + raw_reason = result.reason or "" + error_code = _ERROR_CODES.get(raw_reason) + + if error_code is not None: + 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..8b401c5 --- /dev/null +++ b/featbit_openfeature/provider.py @@ -0,0 +1,263 @@ +import logging +import threading +from typing import Any, Mapping, Optional, Sequence, Union + +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 thread-safe ``FBClient``. + + The provider does not own the client. ``shutdown`` removes the listener 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__() + 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 = None # type: Optional[_FlagChangeListener] + + @property + def client(self) -> FBClient: + """Return 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: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails: + 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: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails: + 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: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails: + 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: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails: + return self._resolve_value( + FlagType.FLOAT, flag_key, default_value, evaluation_context + ) + + def resolve_object_details( + self, + flag_key: str, + default_value: Union[ + Sequence[FlagValueType], Mapping[str, FlagValueType] + ], + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails: + return self._resolve_value( + FlagType.OBJECT, flag_key, default_value, evaluation_context + ) + + def track( + self, + tracking_event_name: str, + evaluation_context: Optional[EvaluationContext] = None, + tracking_event_details: Optional[TrackingEventDetails] = 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: Optional[EvaluationContext], + ) -> FlagResolutionDetails: + 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: + 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: + try: + self.emit_provider_configuration_changed( + ProviderEventDetails(flags_changed=[flag_key]) + ) + except Exception: + logger.exception("Failed to emit OpenFeature configuration change 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" 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/release/description.md b/release/description.md index e87e774..63a50bc 100644 --- a/release/description.md +++ b/release/description.md @@ -1,5 +1,5 @@ -version: 1.1.7 +version: 1.2.0 ## Break changes @@ -7,8 +7,11 @@ No Break changes ## New features -No new features +- add an optional OpenFeature Python server provider +- expose the stable variation ID in evaluation details for OpenFeature variants +- map OpenFeature contexts, typed resolutions, tracking, and configuration events ## Updates -- handle python versions 3.12.x \ No newline at end of file +- keep the base SDK compatible with Python 3.6 through 3.12 +- require Python 3.10 or later only when using the `openfeature` extra diff --git a/release/package.json b/release/package.json index a478856..9ee23cc 100644 --- a/release/package.json +++ b/release/package.json @@ -1,3 +1,3 @@ { - "version":"1.1.7" -} \ No newline at end of file + "version":"1.2.0" +} diff --git a/setup.py b/setup.py index 91749b9..90b20f1 100644 --- a/setup.py +++ b/setup.py @@ -28,6 +28,8 @@ def parse_requirements(filename): author='Dian SUN', author_email='featbit.master@gmail.com', packages=find_packages(), + package_data={"featbit_openfeature": ["py.typed"]}, + include_package_data=True, url='https://github.com/featbit/featbit-python-sdk', project_urls={ 'Code': 'https://github.com/featbit/featbit-python-sdk', @@ -53,7 +55,8 @@ def parse_requirements(filename): 'Programming Language :: Python :: 3.12', ], extras_require={ - "dev": dev_reqs + "dev": dev_reqs, + "openfeature": ["openfeature-sdk>=0.10,<1"] }, tests_require=dev_reqs, python_requires='>=3.6, <3.13' diff --git a/tests/openfeature/__init__.py b/tests/openfeature/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/openfeature/fixtures/bootstrap.json b/tests/openfeature/fixtures/bootstrap.json new file mode 100644 index 0000000..75d5de2 --- /dev/null +++ b/tests/openfeature/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/openfeature/test_context_converter.py b/tests/openfeature/test_context_converter.py new file mode 100644 index 0000000..3316566 --- /dev/null +++ b/tests/openfeature/test_context_converter.py @@ -0,0 +1,101 @@ +# flake8: noqa: E402 + +import logging +from datetime import datetime, timedelta, timezone + +import pytest + +pytest.importorskip("openfeature") + +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import TargetingKeyMissingError + +from featbit_openfeature.impl.context_converter import EvaluationContextConverter + + +@pytest.fixture +def converter(): + 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_mutation(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/openfeature/test_details_converter.py b/tests/openfeature/test_details_converter.py new file mode 100644 index 0000000..84d6418 --- /dev/null +++ b/tests/openfeature/test_details_converter.py @@ -0,0 +1,75 @@ +# flake8: noqa: E402 + +import pytest + +pytest.importorskip("openfeature") + +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(): + 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/openfeature/test_offline_integration.py b/tests/openfeature/test_offline_integration.py new file mode 100644 index 0000000..6d1a4fd --- /dev/null +++ b/tests/openfeature/test_offline_integration.py @@ -0,0 +1,44 @@ +# flake8: noqa: E402 + +from pathlib import Path + +import pytest + +pytest.importorskip("openfeature") + +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/openfeature/test_provider.py b/tests/openfeature/test_provider.py new file mode 100644 index 0000000..ac9b330 --- /dev/null +++ b/tests/openfeature/test_provider.py @@ -0,0 +1,292 @@ +# flake8: noqa: E402 + +from concurrent.futures import ThreadPoolExecutor +from unittest.mock import Mock + +import pytest + +pytest.importorskip("openfeature") + +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) + 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_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_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( + "user-{}".format(index), + {"name": "User {}".format(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 06d29d1e79f17f09a6c412000bcf1648a21ba673 Mon Sep 17 00:00:00 2001 From: WuJiayi0307 Date: Thu, 23 Jul 2026 00:37:29 +0800 Subject: [PATCH 3/5] fix: address OpenFeature provider review feedback --- README.md | 2 +- featbit_openfeature/provider.py | 6 +++++- release/description.md | 4 ++-- tests/openfeature/test_provider.py | 18 ++++++++++++++++++ tests/test_fbclient.py | 1 + 5 files changed, 27 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 17039cf..0fe7c5b 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ If you want to use your own data source, see [Offline Mode](#offline-mode). ## Get Started ### Installation -install the sdk in using pip, this version of the SDK is compatible with Python 3.6 through 3.12. +Install the SDK using pip. This version is compatible with Python 3.6 through 3.12. ```shell pip install fb-python-sdk diff --git a/featbit_openfeature/provider.py b/featbit_openfeature/provider.py index 8b401c5..d813ba7 100644 --- a/featbit_openfeature/provider.py +++ b/featbit_openfeature/provider.py @@ -84,7 +84,6 @@ def initialize(self, evaluation_context: EvaluationContext) -> None: def shutdown(self) -> None: with self._lifecycle_lock: listener = self._flag_change_listener - self._flag_change_listener = None if listener is None: return @@ -92,6 +91,11 @@ def shutdown(self) -> None: self._client.flag_tracker.remove_flag_change_notifier(listener) except Exception: logger.exception("Failed to remove FeatBit flag change listener") + return + + with self._lifecycle_lock: + if self._flag_change_listener is listener: + self._flag_change_listener = None def get_metadata(self) -> Metadata: return Metadata("featbit-openfeature-server") diff --git a/release/description.md b/release/description.md index 63a50bc..10e3bc0 100644 --- a/release/description.md +++ b/release/description.md @@ -1,9 +1,9 @@ version: 1.2.0 -## Break changes +## Breaking changes -No Break changes +No breaking changes ## New features diff --git a/tests/openfeature/test_provider.py b/tests/openfeature/test_provider.py index ac9b330..d730803 100644 --- a/tests/openfeature/test_provider.py +++ b/tests/openfeature/test_provider.py @@ -234,6 +234,24 @@ def test_flag_change_callback_exception_never_escapes(): 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_track_maps_numeric_value_and_context(): client = make_client() provider = FeatBitProvider(client) diff --git a/tests/test_fbclient.py b/tests/test_fbclient.py index c8e9373..414a1fd 100644 --- a/tests/test_fbclient.py +++ b/tests/test_fbclient.py @@ -37,6 +37,7 @@ def test_eval_detail_variation_id_is_backward_compatible(): "test reason", True, "flag-key", "Flag name", "variation-id" ) assert detail_with_id.variation_id == "variation-id" + assert detail_with_id.to_json_dict() == legacy_detail.to_json_dict() def make_fb_client(update_processor_imp, event_processor_imp, start_wait=15.): From 824aee82392ba39f2520d1b15ff27f4822e76ad5 Mon Sep 17 00:00:00 2001 From: WuJiayi0307 Date: Thu, 23 Jul 2026 22:30:39 +0800 Subject: [PATCH 4/5] refactor: move OpenFeature provider to dedicated repository --- .github/workflows/code-quality.yaml | 3 - MANIFEST.in | 2 - README.md | 28 +- docs/openfeature.md | 103 ------ fbclient/interfaces.py | 20 +- fbclient/status.py | 33 +- featbit_openfeature/__init__.py | 5 - featbit_openfeature/impl/__init__.py | 1 - featbit_openfeature/impl/context_converter.py | 75 ----- featbit_openfeature/impl/details_converter.py | 57 ---- featbit_openfeature/provider.py | 267 --------------- featbit_openfeature/py.typed | 1 - release/description.md | 14 +- release/package.json | 2 +- setup.py | 5 +- tests/openfeature/__init__.py | 0 tests/openfeature/fixtures/bootstrap.json | 41 --- tests/openfeature/test_context_converter.py | 101 ------ tests/openfeature/test_details_converter.py | 75 ----- tests/openfeature/test_offline_integration.py | 44 --- tests/openfeature/test_provider.py | 310 ------------------ tests/test_data_update_status_provider.py | 33 ++ 22 files changed, 103 insertions(+), 1117 deletions(-) delete mode 100644 docs/openfeature.md delete mode 100644 featbit_openfeature/__init__.py delete mode 100644 featbit_openfeature/impl/__init__.py delete mode 100644 featbit_openfeature/impl/context_converter.py delete mode 100644 featbit_openfeature/impl/details_converter.py delete mode 100644 featbit_openfeature/provider.py delete mode 100644 featbit_openfeature/py.typed delete mode 100644 tests/openfeature/__init__.py delete mode 100644 tests/openfeature/fixtures/bootstrap.json delete mode 100644 tests/openfeature/test_context_converter.py delete mode 100644 tests/openfeature/test_details_converter.py delete mode 100644 tests/openfeature/test_offline_integration.py delete mode 100644 tests/openfeature/test_provider.py diff --git a/.github/workflows/code-quality.yaml b/.github/workflows/code-quality.yaml index a1d8417..4bbc780 100644 --- a/.github/workflows/code-quality.yaml +++ b/.github/workflows/code-quality.yaml @@ -30,9 +30,6 @@ jobs: python -m pip install --upgrade pip pip install flake8 pytest pytest-mock if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Install OpenFeature dependency - if: ${{ matrix.python-version == '3.10' || matrix.python-version == '3.11' || matrix.python-version == '3.12' }} - run: pip install "openfeature-sdk>=0.10,<1" - name: Lint with flake8 run: | # stop the build if there are Python syntax errors diff --git a/MANIFEST.in b/MANIFEST.in index b967af8..0feff78 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,5 +2,3 @@ include requirements.txt include README.md include dev-requirements.txt include release/package.json -include featbit_openfeature/py.typed -recursive-include docs *.md diff --git a/README.md b/README.md index 0fe7c5b..851cca8 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,16 @@ if client.update_status_provider.wait_for_OKState(): It's possible to set a timeout in seconds for the `wait_for_OKState` method. If the timeout is reached, the method will return `False` and the client will still be in an uninitialized state. If you do not specify a timeout, the method will wait indefinitely. +You can also observe later connection interruptions and recoveries: + +```python +def on_status_change(state): + print(state.state_type, state.error_track) + +client.update_status_provider.add_listener(on_status_change) +# Remove it during application shutdown: +client.update_status_provider.remove_listener(on_status_change) +``` > To check if the client is ready is optional. Even if the client is not ready, you can still evaluate feature flags, but the default value will be returned if SDK is not yet initialized. @@ -136,6 +146,7 @@ if client.initialize: flag_value = client.variation(flag_key, user, default_value) # evaluate the flag value and get the detail detail = client.variation_detail(flag_key, user, default=None) + print(detail.variation, detail.variation_id, detail.reason) ``` If you would like to get variations of all feature flags in a special environment, you can use `fbclient.client.FBClient.get_all_latest_flag_variations`, SDK will return `fbclient.common_types.AllFlagStates`, that explain the details of all feature flags. `fbclient.common_types.AllFlagStates.get()` returns the detail of a given feature flag key. @@ -227,23 +238,6 @@ client.track_metric(user, event_name, numeric_value); Make sure `track_metric` is called after the related feature flag is evaluated by simply calling `variation` or `variation_detail` otherwise, the custom event may not be included into the experiment result. -## OpenFeature - -This package includes an optional server-side OpenFeature provider for Python -3.10 and later. Install it with: - -```shell -pip install "fb-python-sdk[openfeature]" -``` - -The provider wraps the existing `FBClient`, implements all five typed -OpenFeature resolution methods, exposes the FeatBit variation ID as the -OpenFeature variant, forwards tracking and configuration-change events, and -returns safe fallback details when evaluation fails. - -See [the OpenFeature provider guide](docs/openfeature.md) for setup, lifecycle, -context conversion, reason/error mapping, and a complete example. - ## Getting support - If you have a specific question about using this sdk, we encourage you diff --git a/docs/openfeature.md b/docs/openfeature.md deleted file mode 100644 index f348fa1..0000000 --- a/docs/openfeature.md +++ /dev/null @@ -1,103 +0,0 @@ -# FeatBit OpenFeature provider for Python - -The FeatBit Python SDK includes an optional server-side OpenFeature provider. -It translates the OpenFeature API to the existing, thread-safe `FBClient` and -does not replace FeatBit's WebSocket synchronization, evaluator, event -processor, logging, or storage implementations. - -## Compatibility - -- FeatBit's base SDK keeps its existing Python 3.6–3.12 compatibility range. -- The OpenFeature integration requires Python 3.10 or later because - `openfeature-sdk` 0.10 requires Python 3.10 or later. -- The provider accepts an existing `FBClient`. The application owns that - client and must call `stop()` during application shutdown. - -## Installation - -```shell -pip install "fb-python-sdk[openfeature]" -``` - -## Usage - -```python -from fbclient.client import FBClient -from fbclient.config import Config -from featbit_openfeature import FeatBitProvider -from openfeature import api -from openfeature.evaluation_context import EvaluationContext - -fb_client = FBClient( - Config( - env_secret="your-environment-secret", - event_url="https://app-evaluation.featbit.co", - streaming_url="wss://app-evaluation.featbit.co", - ), - start_wait=0, -) - -api.set_provider_and_wait(FeatBitProvider(fb_client)) -client = api.get_client() - -details = client.get_string_details( - "python-app-release", - "v1", - EvaluationContext( - targeting_key="user-123", - attributes={"name": "Alice", "releaseRing": "canary"}, - ), -) - -print(details.value, details.variant, details.reason) - -# Application shutdown -api.shutdown() -fb_client.stop() -``` - -Use the endpoint values shown on the FeatBit environment's SDK connection -page. Self-hosted installations normally use their own event and streaming -URLs. - -## Context mapping - -| OpenFeature field | FeatBit user field | Behavior | -| --- | --- | --- | -| `targeting_key` | `key` | Required; non-empty `attributes["key"]` is a compatibility fallback. | -| `attributes["name"]` | `name` | Falls back to the targeting key. | -| string, boolean, integer, float | customized property | Preserved. | -| `datetime` | customized property | Converted to ISO 8601; naive values use UTC. | -| list, map, or other structured value | — | Ignored because `FBUser` only retains scalar customized properties. | - -## Evaluation mapping - -The provider implements all five typed OpenFeature resolution methods: -boolean, string, integer, float, and object. Successful evaluations expose -FeatBit's variation ID as OpenFeature's `variant`. - -| FeatBit reason | OpenFeature result | -| --- | --- | -| `flag off` | `DISABLED` | -| `target match`, `rule match` | `TARGETING_MATCH` | -| `client not ready` | `ERROR` / `PROVIDER_NOT_READY` | -| `flag not found` | `ERROR` / `FLAG_NOT_FOUND` | -| `user not specified` | `ERROR` / `TARGETING_KEY_MISSING` | -| `wrong type` | `ERROR` / `TYPE_MISMATCH` | -| unexpected exception | fallback value / `GENERAL` | - -All typed resolution methods catch unexpected SDK or conversion errors and -return the caller's default value with OpenFeature error details. They do not -propagate those exceptions into application evaluation code. OpenFeature -provider initialization may still report `ProviderNotReadyError` or -`ProviderFatalError`, as required by the OpenFeature lifecycle. - -## Provider events and tracking - -- FeatBit flag-change notifications are emitted as - `PROVIDER_CONFIGURATION_CHANGED` events. -- `track()` maps to `FBClient.track_metric()`; numeric tracking values are - preserved. OpenFeature tracking attributes are ignored because FeatBit's - current metric API does not accept them. -- `shutdown()` is idempotent and removes only the provider's flag-change - listener. It does not stop the injected `FBClient`. diff --git a/fbclient/interfaces.py b/fbclient/interfaces.py index 084ca1c..6994f59 100644 --- a/fbclient/interfaces.py +++ b/fbclient/interfaces.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Mapping, Optional +from typing import Callable, Mapping, Optional from fbclient.category import Category from fbclient.common_types import FBEvent @@ -124,6 +124,24 @@ def wait_for_OKState(self, timeout: float) -> bool: """ pass + def add_listener(self, listener: Callable[[State], None]): + """ + Registers a listener for update-processing status changes. + + The default implementation is a no-op so existing custom status + providers remain backward compatible. + """ + pass + + def remove_listener(self, listener: Callable[[State], None]): + """ + Removes a previously registered update-status listener. + + The default implementation is a no-op so existing custom status + providers remain backward compatible. + """ + pass + class DataStorage(ABC): """ diff --git a/fbclient/status.py b/fbclient/status.py index 93242da..3092195 100644 --- a/fbclient/status.py +++ b/fbclient/status.py @@ -1,6 +1,6 @@ import threading from time import time -from typing import Mapping +from typing import Callable, List, Mapping from fbclient.category import Category from fbclient.interfaces import DataStorage, DataUpdateStatusProvider @@ -15,6 +15,7 @@ def __init__(self, storage: DataStorage): self.__storage = storage self.__current_state = State.intializing_state() self.__lock = threading.Condition(threading.Lock()) + self.__listeners: List[Callable[[State], None]] = [] def init(self, all_data: Mapping[Category, Mapping[str, dict]], version: int = 0) -> bool: try: @@ -55,6 +56,8 @@ def current_state(self) -> State: def update_state(self, new_state: State): if not new_state: return + listeners = () + state = None with self.__lock: old_state_type = self.__current_state.state_type new_state_type = new_state.state_type @@ -70,6 +73,34 @@ def update_state(self, new_state: State): self.__current_state = State(new_state_type, state_since, error) # wakes up all threads waiting for the ok state to check the new state self.__lock.notify_all() + state = self.__current_state + listeners = tuple(self.__listeners) + + if state is not None: + for listener in listeners: + try: + listener(state) + except Exception: + log.exception('FB Python SDK: Update status listener failed') + + def add_listener(self, listener: Callable[[State], None]): + if not callable(listener): + return + with self.__lock: + try: + if listener not in self.__listeners: + self.__listeners.append(listener) + except Exception: + log.exception('FB Python SDK: Could not add update status listener') + + def remove_listener(self, listener: Callable[[State], None]): + with self.__lock: + try: + self.__listeners.remove(listener) + except ValueError: + pass + except Exception: + log.exception('FB Python SDK: Could not remove update status listener') def wait_for_OKState(self, timeout: float = 0) -> bool: _timeout = 0 if timeout is None or timeout <= 0 else timeout diff --git a/featbit_openfeature/__init__.py b/featbit_openfeature/__init__.py deleted file mode 100644 index c3e71e0..0000000 --- a/featbit_openfeature/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""OpenFeature provider backed by the FeatBit Python server SDK.""" - -from featbit_openfeature.provider import FeatBitProvider - -__all__ = ["FeatBitProvider"] diff --git a/featbit_openfeature/impl/__init__.py b/featbit_openfeature/impl/__init__.py deleted file mode 100644 index eae6109..0000000 --- a/featbit_openfeature/impl/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Internal conversion helpers for the FeatBit OpenFeature provider.""" diff --git a/featbit_openfeature/impl/context_converter.py b/featbit_openfeature/impl/context_converter.py deleted file mode 100644 index 65ee6a1..0000000 --- a/featbit_openfeature/impl/context_converter.py +++ /dev/null @@ -1,75 +0,0 @@ -import logging -from datetime import datetime, timezone -from typing import Any, Dict, Optional - -from openfeature.evaluation_context import EvaluationContext -from openfeature.exception import TargetingKeyMissingError - - -logger = logging.getLogger("featbit-openfeature-server") - -_RESERVED_ATTRIBUTES = {"key", "keyid", "targetingKey", "name"} -_UNSUPPORTED_ATTRIBUTE = object() - - -class EvaluationContextConverter: - """Convert an OpenFeature evaluation context to a FeatBit user mapping.""" - - def to_fb_user(self, context: Optional[EvaluationContext]) -> 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 = {"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 - - 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 diff --git a/featbit_openfeature/impl/details_converter.py b/featbit_openfeature/impl/details_converter.py deleted file mode 100644 index 1044422..0000000 --- a/featbit_openfeature/impl/details_converter.py +++ /dev/null @@ -1,57 +0,0 @@ -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: - """Convert FeatBit details to OpenFeature resolution details.""" - - def to_resolution_details( - self, result: EvalDetail, resolved_value: Any - ) -> FlagResolutionDetails: - raw_reason = result.reason or "" - error_code = _ERROR_CODES.get(raw_reason) - - if error_code is not None: - 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 deleted file mode 100644 index d813ba7..0000000 --- a/featbit_openfeature/provider.py +++ /dev/null @@ -1,267 +0,0 @@ -import logging -import threading -from typing import Any, Mapping, Optional, Sequence, Union - -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 thread-safe ``FBClient``. - - The provider does not own the client. ``shutdown`` removes the listener 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__() - 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 = None # type: Optional[_FlagChangeListener] - - @property - def client(self) -> FBClient: - """Return 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 - - 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") - return - - with self._lifecycle_lock: - if self._flag_change_listener is listener: - self._flag_change_listener = None - - def get_metadata(self) -> Metadata: - return Metadata("featbit-openfeature-server") - - def resolve_boolean_details( - self, - flag_key: str, - default_value: bool, - evaluation_context: Optional[EvaluationContext] = None, - ) -> FlagResolutionDetails: - 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: Optional[EvaluationContext] = None, - ) -> FlagResolutionDetails: - 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: Optional[EvaluationContext] = None, - ) -> FlagResolutionDetails: - 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: Optional[EvaluationContext] = None, - ) -> FlagResolutionDetails: - return self._resolve_value( - FlagType.FLOAT, flag_key, default_value, evaluation_context - ) - - def resolve_object_details( - self, - flag_key: str, - default_value: Union[ - Sequence[FlagValueType], Mapping[str, FlagValueType] - ], - evaluation_context: Optional[EvaluationContext] = None, - ) -> FlagResolutionDetails: - return self._resolve_value( - FlagType.OBJECT, flag_key, default_value, evaluation_context - ) - - def track( - self, - tracking_event_name: str, - evaluation_context: Optional[EvaluationContext] = None, - tracking_event_details: Optional[TrackingEventDetails] = 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: Optional[EvaluationContext], - ) -> FlagResolutionDetails: - 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: - 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: - try: - self.emit_provider_configuration_changed( - ProviderEventDetails(flags_changed=[flag_key]) - ) - except Exception: - logger.exception("Failed to emit OpenFeature configuration change 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" diff --git a/featbit_openfeature/py.typed b/featbit_openfeature/py.typed deleted file mode 100644 index 8b13789..0000000 --- a/featbit_openfeature/py.typed +++ /dev/null @@ -1 +0,0 @@ - diff --git a/release/description.md b/release/description.md index 10e3bc0..4c962a0 100644 --- a/release/description.md +++ b/release/description.md @@ -1,17 +1,15 @@ -version: 1.2.0 +version: 1.1.8 -## Breaking changes +## Break changes -No breaking changes +No Break changes ## New features -- add an optional OpenFeature Python server provider -- expose the stable variation ID in evaluation details for OpenFeature variants -- map OpenFeature contexts, typed resolutions, tracking, and configuration events +- expose the stable variation ID in evaluation details +- add data-update status change listeners ## Updates -- keep the base SDK compatible with Python 3.6 through 3.12 -- require Python 3.10 or later only when using the `openfeature` extra +- handle Python versions 3.12.x diff --git a/release/package.json b/release/package.json index 9ee23cc..8f7d62d 100644 --- a/release/package.json +++ b/release/package.json @@ -1,3 +1,3 @@ { - "version":"1.2.0" + "version":"1.1.8" } diff --git a/setup.py b/setup.py index 90b20f1..91749b9 100644 --- a/setup.py +++ b/setup.py @@ -28,8 +28,6 @@ def parse_requirements(filename): author='Dian SUN', author_email='featbit.master@gmail.com', packages=find_packages(), - package_data={"featbit_openfeature": ["py.typed"]}, - include_package_data=True, url='https://github.com/featbit/featbit-python-sdk', project_urls={ 'Code': 'https://github.com/featbit/featbit-python-sdk', @@ -55,8 +53,7 @@ def parse_requirements(filename): 'Programming Language :: Python :: 3.12', ], extras_require={ - "dev": dev_reqs, - "openfeature": ["openfeature-sdk>=0.10,<1"] + "dev": dev_reqs }, tests_require=dev_reqs, python_requires='>=3.6, <3.13' diff --git a/tests/openfeature/__init__.py b/tests/openfeature/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/openfeature/fixtures/bootstrap.json b/tests/openfeature/fixtures/bootstrap.json deleted file mode 100644 index 75d5de2..0000000 --- a/tests/openfeature/fixtures/bootstrap.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "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/openfeature/test_context_converter.py b/tests/openfeature/test_context_converter.py deleted file mode 100644 index 3316566..0000000 --- a/tests/openfeature/test_context_converter.py +++ /dev/null @@ -1,101 +0,0 @@ -# flake8: noqa: E402 - -import logging -from datetime import datetime, timedelta, timezone - -import pytest - -pytest.importorskip("openfeature") - -from openfeature.evaluation_context import EvaluationContext -from openfeature.exception import TargetingKeyMissingError - -from featbit_openfeature.impl.context_converter import EvaluationContextConverter - - -@pytest.fixture -def converter(): - 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_mutation(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/openfeature/test_details_converter.py b/tests/openfeature/test_details_converter.py deleted file mode 100644 index 84d6418..0000000 --- a/tests/openfeature/test_details_converter.py +++ /dev/null @@ -1,75 +0,0 @@ -# flake8: noqa: E402 - -import pytest - -pytest.importorskip("openfeature") - -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(): - 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/openfeature/test_offline_integration.py b/tests/openfeature/test_offline_integration.py deleted file mode 100644 index 6d1a4fd..0000000 --- a/tests/openfeature/test_offline_integration.py +++ /dev/null @@ -1,44 +0,0 @@ -# flake8: noqa: E402 - -from pathlib import Path - -import pytest - -pytest.importorskip("openfeature") - -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/openfeature/test_provider.py b/tests/openfeature/test_provider.py deleted file mode 100644 index d730803..0000000 --- a/tests/openfeature/test_provider.py +++ /dev/null @@ -1,310 +0,0 @@ -# flake8: noqa: E402 - -from concurrent.futures import ThreadPoolExecutor -from unittest.mock import Mock - -import pytest - -pytest.importorskip("openfeature") - -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) - 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_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_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( - "user-{}".format(index), - {"name": "User {}".format(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)] diff --git a/tests/test_data_update_status_provider.py b/tests/test_data_update_status_provider.py index 912191b..abb52aa 100644 --- a/tests/test_data_update_status_provider.py +++ b/tests/test_data_update_status_provider.py @@ -93,6 +93,39 @@ def test_update_state(data_updator): assert data_updator.current_state.state_type == StateType.INTERRUPTED +def test_status_listener_receives_changes_and_can_be_removed(data_updator): + received = [] + + def listener(state): + received.append(state.state_type) + + data_updator.add_listener(listener) + data_updator.add_listener(listener) + data_updator.add_listener(None) + data_updator.remove_listener(lambda _state: None) + data_updator.update_state(State.ok_state()) + data_updator.update_state(State.interrupted_state("network", "disconnected")) + data_updator.remove_listener(listener) + data_updator.update_state(State.ok_state()) + + assert received == [StateType.OK, StateType.INTERRUPTED] + + +def test_status_listener_exception_does_not_escape_or_block_others(data_updator): + received = [] + + def failing_listener(_state): + raise RuntimeError("listener failure") + + data_updator.add_listener(failing_listener) + data_updator.add_listener(lambda state: received.append(state.state_type)) + + data_updator.update_state(State.ok_state()) + + assert received == [StateType.OK] + assert data_updator.current_state.state_type == StateType.OK + + def test_wait_for_OKState(data_updator): assert not data_updator.wait_for_OKState(timeout=0.1) data_updator.update_state(State.ok_state()) From 32e0f9c0ef843df0892fe5fb156a7eea23667c17 Mon Sep 17 00:00:00 2001 From: WuJiayi0307 Date: Thu, 23 Jul 2026 22:44:58 +0800 Subject: [PATCH 5/5] fix: preserve update status notification order --- fbclient/status.py | 30 ++++++++++++++++----- tests/test_data_update_status_provider.py | 33 +++++++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/fbclient/status.py b/fbclient/status.py index 3092195..1a65bae 100644 --- a/fbclient/status.py +++ b/fbclient/status.py @@ -1,6 +1,7 @@ import threading +from collections import deque from time import time -from typing import Callable, List, Mapping +from typing import Callable, Deque, List, Mapping, Tuple from fbclient.category import Category from fbclient.interfaces import DataStorage, DataUpdateStatusProvider @@ -16,6 +17,10 @@ def __init__(self, storage: DataStorage): self.__current_state = State.intializing_state() self.__lock = threading.Condition(threading.Lock()) self.__listeners: List[Callable[[State], None]] = [] + self.__pending_notifications: Deque[ + Tuple[State, Tuple[Callable[[State], None], ...]] + ] = deque() + self.__publishing_notifications = False def init(self, all_data: Mapping[Category, Mapping[str, dict]], version: int = 0) -> bool: try: @@ -56,8 +61,7 @@ def current_state(self) -> State: def update_state(self, new_state: State): if not new_state: return - listeners = () - state = None + publish_notifications = False with self.__lock: old_state_type = self.__current_state.state_type new_state_type = new_state.state_type @@ -73,10 +77,24 @@ def update_state(self, new_state: State): self.__current_state = State(new_state_type, state_since, error) # wakes up all threads waiting for the ok state to check the new state self.__lock.notify_all() - state = self.__current_state - listeners = tuple(self.__listeners) + self.__pending_notifications.append( + (self.__current_state, tuple(self.__listeners)) + ) + if not self.__publishing_notifications: + self.__publishing_notifications = True + publish_notifications = True + + if publish_notifications: + self.__publish_pending_notifications() + + def __publish_pending_notifications(self): + while True: + with self.__lock: + if not self.__pending_notifications: + self.__publishing_notifications = False + return + state, listeners = self.__pending_notifications.popleft() - if state is not None: for listener in listeners: try: listener(state) diff --git a/tests/test_data_update_status_provider.py b/tests/test_data_update_status_provider.py index abb52aa..01d4e29 100644 --- a/tests/test_data_update_status_provider.py +++ b/tests/test_data_update_status_provider.py @@ -126,6 +126,39 @@ def failing_listener(_state): assert data_updator.current_state.state_type == StateType.OK +def test_status_listener_preserves_concurrent_transition_order(data_updator): + first_callback_started = threading.Event() + release_first_callback = threading.Event() + received = [] + + def listener(state): + if state.state_type == StateType.OK: + first_callback_started.set() + assert release_first_callback.wait(1) + received.append(state.state_type) + + data_updator.add_listener(listener) + first_update = threading.Thread( + target=data_updator.update_state, + args=(State.ok_state(),) + ) + second_update = threading.Thread( + target=data_updator.update_state, + args=(State.interrupted_state("network", "disconnected"),) + ) + + first_update.start() + assert first_callback_started.wait(1) + second_update.start() + second_update.join(1) + release_first_callback.set() + first_update.join(1) + + assert not first_update.is_alive() + assert not second_update.is_alive() + assert received == [StateType.OK, StateType.INTERRUPTED] + + def test_wait_for_OKState(data_updator): assert not data_updator.wait_for_OKState(timeout=0.1) data_updator.update_state(State.ok_state())