Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
include requirements.txt
include README.md
include dev-requirements.txt
include release/package.json
include release/package.json
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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.
Expand Down
3 changes: 0 additions & 3 deletions fbclient/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -58,7 +56,6 @@ def set_config(config: Config):
"""
global __config
global __client
global __lock

try:
__lock.write_lock()
Expand Down
15 changes: 12 additions & 3 deletions fbclient/common_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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":
Expand Down
20 changes: 19 additions & 1 deletion fbclient/interfaces.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down
51 changes: 50 additions & 1 deletion fbclient/status.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 4 additions & 3 deletions release/description.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@

version: 1.1.7
version: 1.1.8

## Break changes

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
- handle Python versions 3.12.x
4 changes: 2 additions & 2 deletions release/package.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"version":"1.1.7"
}
"version":"1.1.8"
}
66 changes: 66 additions & 0 deletions tests/test_data_update_status_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
15 changes: 15 additions & 0 deletions tests/test_fbclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down