diff --git a/MANIFEST.in b/MANIFEST.in index 971366d..0feff78 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ include requirements.txt include README.md include dev-requirements.txt -include release/package.json \ No newline at end of file +include release/package.json diff --git a/README.md b/README.md index 5e79b3f..851cca8 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 using pip. This version is compatible with Python 3.6 through 3.12. ```shell pip install fb-python-sdk @@ -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. 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/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/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..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 Mapping +from typing import Callable, Deque, List, Mapping, Tuple from fbclient.category import Category from fbclient.interfaces import DataStorage, DataUpdateStatusProvider @@ -15,6 +16,11 @@ 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]] = [] + 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: @@ -55,6 +61,7 @@ def current_state(self) -> State: def update_state(self, new_state: State): if not new_state: return + publish_notifications = False with self.__lock: old_state_type = self.__current_state.state_type new_state_type = new_state.state_type @@ -70,6 +77,48 @@ 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() + 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() + + 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/release/description.md b/release/description.md index e87e774..4c962a0 100644 --- a/release/description.md +++ b/release/description.md @@ -1,5 +1,5 @@ -version: 1.1.7 +version: 1.1.8 ## Break changes @@ -7,8 +7,9 @@ No Break changes ## New features -No new features +- expose the stable variation ID in evaluation details +- add data-update status change listeners ## Updates -- handle python versions 3.12.x \ No newline at end of file +- handle Python versions 3.12.x diff --git a/release/package.json b/release/package.json index a478856..8f7d62d 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.1.8" +} diff --git a/tests/test_data_update_status_provider.py b/tests/test_data_update_status_provider.py index 912191b..01d4e29 100644 --- a/tests/test_data_update_status_provider.py +++ b/tests/test_data_update_status_provider.py @@ -93,6 +93,72 @@ 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_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()) diff --git a/tests/test_fbclient.py b/tests/test_fbclient.py index 5ff30fc..414a1fd 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,17 @@ 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" + 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.): config = Config(FAKE_ENV_SECRET, event_url=FAKE_URL, @@ -118,6 +130,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 +146,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 +252,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