From 19afd2069e6919eedefd33edab02837c2f7b14e0 Mon Sep 17 00:00:00 2001 From: ds Date: Sat, 8 Apr 2023 01:35:29 +0200 Subject: [PATCH 1/7] feature/v1.1.0: refactor common types --- fbclient/client.py | 52 ++++++---------- fbclient/common_types.py | 51 ++++++++-------- tests/test_fbclient.py | 129 +++++++++++++++++++-------------------- 3 files changed, 108 insertions(+), 124 deletions(-) diff --git a/fbclient/client.py b/fbclient/client.py index 80415bd..5333244 100644 --- a/fbclient/client.py +++ b/fbclient/client.py @@ -1,10 +1,10 @@ import json import threading -from distutils.util import strtobool from typing import Any, Mapping, Optional, Tuple from fbclient.category import FEATURE_FLAGS, SEGMENTS -from fbclient.common_types import AllFlagStates, FBUser, FlagState, _EvalResult +from fbclient.common_types import (AllFlagStates, EvalDetail, FBUser, + _EvalResult) from fbclient.config import Config from fbclient.data_storage import NullDataStorage from fbclient.evaluator import (REASON_CLIENT_NOT_READY, REASON_ERROR, @@ -211,51 +211,39 @@ def variation(self, key: str, user: dict, default: Any = None) -> Any: This method will send an event back to feature flag center immediately if no error occurs. + The default value should be a string, boolean, numeric, or json type. + The result of the flag evaluation will be converted to: 1: string if the feature flag is a string type 2: bool if the feature flag is a boolean type - 3: Python object if the feature flag is a json type - 4: float/int if the feature flag is a numeric type + 3: float/int if the feature flag is a numeric type + 4: Mapping or Iterable of any string, bool, float/int, or Mapping, if the feature flag is a json type :param key: the unique key for the feature flag :param user: the attributes of the user :param default: the default value of the flag, to be used if the return value is not available - :return: one of the flag's values in any type in any type of string, bool, json(Python object), and float + :return: one of the flag's values in any type in any type of string, bool, float, json or the default value if flag evaluation fails + :raises: ValueError if the default is not a string, boolean, numeric, or json type """ er = self._evaluate_internal(key, user, default) return cast_variation_by_flag_type(er.flag_type, er.value) - def variation_detail(self, key: str, user: dict, default: Any = None) -> FlagState: + def variation_detail(self, key: str, user: dict, default: Any = None) -> EvalDetail: """"Return the variation of a feature flag for a given user, but also provides additional information - about how this value was calculated, in the property `data` of the :class:`fbclient.common_types.FlagState`. + about how this value was calculated, in the property `data` of the :class:`fbclient.common_types.EvalDetail`. This method will send an event back to feature flag center immediately if no error occurs. - :param key: the unique key for the feature flag - :param user: the attributes of the user - :param default: the default value of the flag, to be used if the return value is not available - :return: an :class:`fbclient.common_types.FlagState` object - """ - return self._evaluate_internal(key, user, default).to_flag_state - - def is_enabled(self, key: str, user: dict) -> bool: - """ - Return the bool value for a feature flag for a given user. it's strongly recommended to call this method - only in a bool feature flag, otherwise the results may not be what you expect - - This method will send an event back to feature flag center immediately if no error occurs. + The default value should be a string, boolean, numeric, or json type. :param key: the unique key for the feature flag :param user: the attributes of the user - :return: True or False - + :param default: the default value of the flag, to be used if the return value is not available + :return: an :class:`fbclient.common_types.EvalDetail` object + :raises: ValueError if the default is not a string, boolean, numeric, or json type """ - try: - value = self.variation(key, user, False) - return bool(strtobool(str(value))) - except ValueError: - return False + return self._evaluate_internal(key, user, default).to_evail_detail def get_all_latest_flag_variations(self, user: dict) -> AllFlagStates: """ @@ -268,12 +256,12 @@ def get_all_latest_flag_variations(self, user: dict) -> AllFlagStates: if SDK has not been initialized or the user invalid) """ all_flag_details = {} - message = "" + reason = "" success = True try: if not self.initialize: log.warning('FB Python SDK: Evaluation called before Java SDK client initialized for feature flag') - message = REASON_CLIENT_NOT_READY + reason = REASON_CLIENT_NOT_READY success = False else: try: @@ -285,15 +273,15 @@ def get_all_latest_flag_variations(self, user: dict) -> AllFlagStates: all_flag_details[er.to_evail_detail] = fb_event except ValueError as ve: log.warning('FB Python SDK: %s' % str(ve)) - message = REASON_USER_NOT_SPECIFIED + reason = REASON_USER_NOT_SPECIFIED success = False except: raise except Exception as e: log.exception('FB Python SDK: unexpected error in evaluation: %s' % str(e)) - message = REASON_ERROR + reason = REASON_ERROR success = False - return AllFlagStates(success, message, all_flag_details, self._event_handler) + return AllFlagStates(success, reason, all_flag_details, self._event_handler) def is_flag_known(self, key: str) -> bool: """ diff --git a/fbclient/common_types.py b/fbclient/common_types.py index feec210..7718500 100644 --- a/fbclient/common_types.py +++ b/fbclient/common_types.py @@ -1,6 +1,6 @@ import json from abc import ABC, abstractmethod -from typing import Any, Callable, Dict, Iterable, Mapping, Optional +from typing import Any, Callable, Dict, Mapping, Optional from fbclient.utils import cast_variation_by_flag_type, is_numeric @@ -10,12 +10,12 @@ __NO_VARIATION__ = 'NE' +__FLAG_NOT_FOUND__ = 'flag not found' + __FLAG_KEY_UNKNOWN__ = 'flag key unknown' __FLAG_NAME_UNKNOWN__ = 'flag name unknown' -__FLAG_VALUE_UNKNOWN__ = 'flag value unknown' - class Jsonfy(ABC): @@ -142,14 +142,14 @@ class BasicFlagState: """Abstract class representing flag state after feature flag evaluaion """ - def __init__(self, success: bool, message: str): + def __init__(self, success: bool, reason: str): """Constructs an instance. :param success: True if successful - :param message: the state of last evaluation; the value is OK if successful + :param reason: the state of last evaluation; the value is OK if successful """ self._success = success - self._message = 'OK' if success else message + self._reason = 'OK' if success else reason @property def success(self) -> bool: @@ -158,10 +158,10 @@ def success(self) -> bool: return self._success @property - def message(self) -> str: - """Message representing the state of last evaluation; the value is OK if successful + def reason(self) -> str: + """reason the state of last evaluation; the value is OK if successful """ - return self._message + return self._reason class FlagState(BasicFlagState, Jsonfy): @@ -175,14 +175,14 @@ class FlagState(BasicFlagState, Jsonfy): 4: float/int if the feature flag is a numeric type """ - def __init__(self, success: bool, message: str, data: EvalDetail): + def __init__(self, success: bool, reason: str, data: EvalDetail): """Constructs an instance. :param success: True if successful - :param message: the state of last evaluation; the value is OK if successful + :param reason: the state of last evaluation; the value is OK if successful :param data: the result of a flag evaluation with information about how it was calculated """ - super().__init__(success, message) + super().__init__(success, reason) self._data = data @property @@ -192,7 +192,7 @@ def data(self) -> EvalDetail: def to_json_dict(self) -> dict: return {'success': self.success, - 'message': self.message, + 'reason': self.reason, 'data': self._data.to_json_dict() if self._data else None} @@ -201,42 +201,41 @@ class AllFlagStates(BasicFlagState, Jsonfy): :func:`get(key_name)` to get the state for a given feature flag key """ - def __init__(self, success: bool, message: str, + def __init__(self, success: bool, reason: str, data: Mapping[EvalDetail, "FBEvent"], event_handler: Callable[["FBEvent"], None]): """Constructs an instance. :param success: True if successful - :param message: the state of last evaluation; the value is OK if successful - :param data: a dictionary containing state of all feature flags and their events + :param reason: the state of last evaluation; the value is OK if successful + :param data: a dictionary containing latest evaluation of all feature flags and their events :event_handler: callback function used to send events to feature flag center """ - super().__init__(success, message) + super().__init__(success, reason) self._data = dict((ed.key_name, (ed, fb_event)) for ed, fb_event in data.items()) if data else {} self._event_handler = event_handler - @property - def key_names(self) -> Iterable[Optional[str]]: - """Return key names of all feature flag - """ - return self._data.keys() - - def get(self, key_name: str) -> Optional[EvalDetail]: + def get(self, key_name: str, default: Any) -> EvalDetail: """Return the flag evaluation details of a given feature flag key This method will send event to back to feature flag center immediately + The default value should be a string, boolean, numeric, or json type. + :param key_name: key name of the flag :return: an :class:`fbclient.common_types.EvalDetail` object """ ed, fb_event = self._data.get(key_name, (None, False)) if self._event_handler and fb_event: self._event_handler(fb_event) - return ed + return ed if ed is not None else EvalDetail(reason=__FLAG_NOT_FOUND__, + variation=default, + key_name=key_name, + name=__FLAG_NAME_UNKNOWN__) def to_json_dict(self) -> dict: return {'success': self.success, - 'message': self.message, + 'message': self.reason, 'data': [ed.to_json_dict() for ed, _ in self._data.values()] if self._data else []} diff --git a/tests/test_fbclient.py b/tests/test_fbclient.py index e251a7f..d9ce279 100644 --- a/tests/test_fbclient.py +++ b/tests/test_fbclient.py @@ -1,4 +1,5 @@ import base64 +from datetime import datetime from pathlib import Path from unittest.mock import patch @@ -107,59 +108,51 @@ def start(): mock_start_method.side_effect = start with make_fb_client(NullUpdateProcessor, NullEventProcessor, start_wait=0.1) as client: assert not client.initialize - flag_state = client.variation_detail("ff-test-bool", USER_1, False) - assert not flag_state.success - assert not flag_state.data.variation - assert flag_state.data.reason == REASON_CLIENT_NOT_READY + detail = client.variation_detail("ff-test-bool", USER_1, False) + assert not detail.variation + assert detail.reason == REASON_CLIENT_NOT_READY all_states = client.get_all_latest_flag_variations(USER_1) # type: ignore assert not all_states.success - assert all_states.message == REASON_CLIENT_NOT_READY + assert all_states.reason == REASON_CLIENT_NOT_READY def test_bool_variation(): with make_fb_client_offline() as client: assert client.initialize - assert client.is_enabled("ff-test-bool", USER_1) - assert client.variation("ff-test-bool", USER_1, False) - flag_state = client.variation_detail("ff-test-bool", USER_2, False) - assert flag_state.success - assert flag_state.data.variation - assert flag_state.data.reason == REASON_TARGET_MATCH - assert not client.is_enabled("ff-test-bool", USER_3) - flag_state = client.variation_detail("ff-test-bool", USER_4, False) - assert flag_state.success - assert flag_state.data.variation - assert flag_state.data.reason == REASON_FALLTHROUGH + assert client.variation("ff-test-bool", USER_1, False) is True + detail = client.variation_detail("ff-test-bool", USER_2, False) + assert detail.variation is True + assert detail.reason == REASON_TARGET_MATCH + 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 + assert detail.reason == REASON_FALLTHROUGH def test_numeric_variation(): with make_fb_client_offline() as client: assert client.initialize assert client.variation("ff-test-number", USER_1, -1) == 1 - flag_state = client.variation_detail("ff-test-number", USER_2, -1) - assert flag_state.success - assert flag_state.data.variation == 33 - assert flag_state.data.reason == REASON_RULE_MATCH + detail = client.variation_detail("ff-test-number", USER_2, -1) + assert detail.variation == 33 + assert detail.reason == REASON_RULE_MATCH assert client.variation("ff-test-number", USER_3, -1) == 86 - flag_state = client.variation_detail("ff-test-number", USER_4, -1) - assert flag_state.success - assert flag_state.data.variation == 9999 - assert flag_state.data.reason == REASON_FALLTHROUGH + detail = client.variation_detail("ff-test-number", USER_4, -1) + assert detail.variation == 9999 + assert detail.reason == REASON_FALLTHROUGH def test_string_variation(): with make_fb_client_offline() as client: assert client.initialize assert client.variation("ff-test-string", USER_CN_PHONE_NUM, 'error') == 'phone number' - flag_state = client.variation_detail("ff-test-string", USER_FR_PHONE_NUM, 'error') - assert flag_state.success - assert flag_state.data.variation == 'phone number' - assert flag_state.data.reason == REASON_RULE_MATCH + detail = client.variation_detail("ff-test-string", USER_FR_PHONE_NUM, 'error') + assert detail.variation == 'phone number' + assert detail.reason == REASON_RULE_MATCH assert client.variation("ff-test-string", USER_EMAIL, 'error') == 'email' - flag_state = client.variation_detail("ff-test-string", USER_1, 'error') - assert flag_state.success - assert flag_state.data.variation == 'others' - assert flag_state.data.reason == REASON_FALLTHROUGH + detail = client.variation_detail("ff-test-string", USER_1, 'error') + assert detail.variation == 'others' + assert detail.reason == REASON_FALLTHROUGH def test_segment(): @@ -167,14 +160,12 @@ def test_segment(): assert client.initialize assert client.variation("ff-test-seg", USER_1, 'error') == 'teamA' assert client.variation("ff-test-seg", USER_2, 'error') == 'teamB' - flag_state = client.variation_detail("ff-test-seg", USER_3, 'error') - assert flag_state.success - assert flag_state.data.variation == 'teamA' - assert flag_state.data.reason == REASON_RULE_MATCH - flag_state = client.variation_detail("ff-test-seg", USER_4, 'error') - assert flag_state.success - assert flag_state.data.variation == 'teamB' - assert flag_state.data.reason == REASON_FALLTHROUGH + detail = client.variation_detail("ff-test-seg", USER_3, 'error') + assert detail.variation == 'teamA' + assert detail.reason == REASON_RULE_MATCH + detail = client.variation_detail("ff-test-seg", USER_4, 'error') + assert detail.variation == 'teamB' + assert detail.reason == REASON_FALLTHROUGH def test_json_variation(): @@ -183,11 +174,10 @@ def test_json_variation(): json_object = client.variation("ff-test-json", USER_1, {}) assert json_object["code"] == 200 assert json_object["reason"] == "you win 100 euros" - flag_state = client.variation_detail("ff-test-json", USER_2, {}) - assert flag_state.success - assert flag_state.data.variation["code"] == 404 - assert flag_state.data.variation["reason"] == "fail to win the lottery" - assert flag_state.data.reason == REASON_FALLTHROUGH + detail = client.variation_detail("ff-test-json", USER_2, {}) + assert detail.variation["code"] == 404 + assert detail.variation["reason"] == "fail to win the lottery" + assert detail.reason == REASON_FALLTHROUGH def test_flag_known(): @@ -205,32 +195,30 @@ def test_get_all_latest_flag_variations(): with make_fb_client_offline() as client: assert client.initialize all_states = client.get_all_latest_flag_variations(USER_1) - ed = all_states.get("ff-test-bool") - assert ed is not None and ed.variation - ed = all_states.get("ff-test-number") + ed = all_states.get("ff-test-bool", False) + assert ed is not None and ed.variation is True + ed = all_states.get("ff-test-number", -1) assert ed is not None and ed.variation == 1 - ed = all_states.get("ff-test-string") + ed = all_states.get("ff-test-string", 'error') assert ed is not None and ed.variation == "others" - ed = all_states.get("ff-test-seg") + ed = all_states.get("ff-test-seg", 'error') assert ed is not None and ed.variation == "teamA" - ed = all_states.get("ff-test-json") + ed = all_states.get("ff-test-json", {}) assert ed is not None and ed.variation["code"] == 200 def test_variation_argument_error(): with make_fb_client_offline() as client: assert client.initialize - flag_state = client.variation_detail("ff-not-existed", USER_1, False) - assert not flag_state.success - assert not flag_state.data.variation - assert flag_state.data.reason == REASON_FLAG_NOT_FOUND - flag_state = client.variation_detail("ff-test-bool", None, False) # type: ignore - assert not flag_state.success - assert not flag_state.data.variation - assert flag_state.data.reason == REASON_USER_NOT_SPECIFIED + detail = client.variation_detail("ff-not-existed", USER_1, False) + assert detail.variation is False + assert detail.reason == REASON_FLAG_NOT_FOUND + detail = client.variation_detail("ff-test-bool", None, False) # type: ignore + assert detail.variation is False + assert detail.reason == REASON_USER_NOT_SPECIFIED all_states = client.get_all_latest_flag_variations(None) # type: ignore - assert not all_states.success - assert all_states.message == REASON_USER_NOT_SPECIFIED + assert all_states.success is False + assert all_states.reason == REASON_USER_NOT_SPECIFIED @patch.object(InMemoryDataStorage, "get_all") @@ -240,10 +228,19 @@ def test_variation_unexpected_error(mock_get_method, mock_get_all_method): mock_get_all_method.side_effect = RuntimeError('test exception') with make_fb_client_offline() as client: assert client.initialize - flag_state = client.variation_detail("ff-test-bool", USER_1, False) - assert not flag_state.success - assert not flag_state.data.variation - assert flag_state.data.reason == REASON_ERROR + detail = client.variation_detail("ff-test-bool", USER_1, False) + assert detail.variation is False + assert detail.reason == REASON_ERROR all_states = client.get_all_latest_flag_variations(USER_1) assert not all_states.success - assert all_states.message == REASON_ERROR + assert all_states.reason == REASON_ERROR + + +def test_variation_error_default_value(): + now = datetime.utcnow() + with make_fb_client_offline() as client: + assert client.initialize + with pytest.raises(ValueError): + client.variation_detail("ff-test-bool", USER_1, now) + with pytest.raises(ValueError): + client.variation("ff-test-bool", USER_1, now) From a3f9d29d353847ff01addc10649b483e692ca630 Mon Sep 17 00:00:00 2001 From: ds Date: Sat, 8 Apr 2023 04:43:54 +0200 Subject: [PATCH 2/7] update readme --- README.md | 126 ++++++++++++++++++++++++++++----------- fbclient/common_types.py | 2 +- fbclient/version.py | 2 +- setup.py | 3 +- tests/test_fbclient.py | 4 +- 5 files changed, 98 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 025a8f4..cad8536 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ ## Introduction -This is the Python Server SDK for the feature management platform FeatBit. It is -intended for use in a multiple-users python server applications. +This is the Python Server-Side SDK for the 100% open-source feature flags management +platform [FeatBit](https://github.com/featbit/featbit). It is intended for use in a multiple-users python server applications. This SDK has two main purposes: @@ -12,35 +12,54 @@ This SDK has two main purposes: ## Data synchonization -We use websocket to make the local data synchronized with the server, and then store them in the memory by default. -Whenever there is any changes to a feature flag or his related data, the changes would be pushed to the SDK, the average -synchronization time is less than **100** ms. Be aware the websocket connection can be interrupted by any error or -internet interruption, but it would be restored automatically right after the problem is gone. +We use websocket to make the local data synchronized with the FeatBit server, and then store them in memory by +default. Whenever there is any change to a feature flag or its related data, this change will be pushed to the SDK and +the average synchronization time is less than 100 ms. Be aware the websocket connection may be interrupted due to +internet outage, but it will be resumed automatically once the problem is gone. -## Offline mode support +If you want to use your own data source, see [Offline](#offline). -In the offline mode, SDK DOES not exchange any data with feature flag center, this mode is only use for internal test for instance. +## Get Started + +### Installation +install the sdk in using pip, this version of the SDK is compatible with Python 3.6 through 3.11. -To open the offline mode: -```python -config = Config(env_secret, event_url, streaming_url, offline=True) +``` +pip install fb-python-sdk ``` -## Evaluation of a feature flag +### Basic usage -SDK will initialize all the related data(feature flags, segments etc.) in the bootstrapping and receive the data updates -in real time, as mentioned in the above +```python +from fbclient import get, set_config +from fbclient.config import Config -After initialization, the SDK has all the feature flags in the memory and all evaluation is done locally and synchronously, the average evaluation time is < **10** ms. +env_secret = '' +event_url = 'http://localhost:5100' +streaming_url = '"ws://localhost:5100"' + +set_config(Config(env_secret, event_url, streaming_url)) +client = get() -## Installation -install the sdk in using pip, this version of the SDK is compatible with Python 3.6 through 3.10. +if client.initialize: + flag_key = '' + user_key = '' + user_name = '' + user = {'key': user_key, 'name': user_name} + detail = client.variation_detail(flag_key, user, default=None) + print(f'flag {flag_key} returns {detail.value} for user {user_key}') + print(f'Reason Description: {detail.reason}') +# should close the client when you don't need it anymore +client.stop() ``` -pip install fb-python-sdk -``` +Note that the _**env_secret**_, _**streaming_url**_ and _**event_url**_ are required to initialize the SDK. + +### Examples -## SDK +- [Python Demo](https://github.com/featbit/featbit-samples/blob/main/samples/dino-game/demo-python/demo_python.py) + +### FBClient Applications SHOULD instantiate a single instance for the lifetime of the application. In the case where an application needs to evaluate feature flags from different environments, you may create multiple clients, but they should still be @@ -64,7 +83,7 @@ if client.initialize: # your code ``` -You can also manage your `fbclient.client.FBClient`, the SDK will be initialized if you call `fbclient.client.FBClient` constructor. +You can also manage your `fbclient.client.FBClient`, the SDK will be initialized if you call `fbclient.client.FBClient` constructor. With constructor, you can set the timeout for initialization, the default value is 15 seconds. ```python from fbclient.config import Config from fbclient.client import FBClient @@ -82,39 +101,68 @@ from fbclient.config import Config from fbclient.client import FBClient client = FFCClient(Config(env_secret), start_wait=0) -if client._update_status_provider.wait_for_OKState(): +if client.update_status_provider.wait_for_OKState(): # your code ``` +### Offline -### Evaluation +In the offline mode, SDK DOES not exchange any data with feature flag center, this mode is only use for internal test for instance. -SDK calculates the value of a feature flag for a given user, and returns a flag vlaue/an object that describes the way that the value was determined. +To open the offline mode: +```python +config = Config(env_secret, event_url, streaming_url, offline=True) +``` +When you put the SDK in offline mode, no insight message is sent to the server and all feature flag evaluations return +fallback values because there are no feature flags or segments available. If you want to use your own data source, +SDK allows users to populate feature flags and segments data from a JSON string. + +Here is an example: [fbclient_test_data.json](tests/fbclient_test_data.json). + +```shell +# replace http://localhost:5100 with your evaluation server url +curl -H "Authorization: " http://localhost:5100/api/public/sdk/server/latest-all > featbit-bootstrap.json +``` + +Then you can use this file to initialize the SDK in offline mode: + +```python +// first load data from file and then +client.initialize_from_external_json(json) +``` +### FBUser `User`: A dictionary of attributes that can affect flag evaluation, usually corresponding to a user of your application. This object contains built-in properties(`key`, `name`). The `key` and `name` are required. The `key` must uniquely identify each user; this could be a username or email address for authenticated users, or a ID for anonymous users. The `name` is used to search your user quickly. You may also define custom properties with arbitrary names and values. -For instance, the custom key should be a string; the custom value should be a string or a number +For instance, the custom key should be a string; the custom value should be a string, number or boolean value + +```python +user = {'key': user_key, 'name': user_name, 'age': age} +``` + +### Evaluation + +SDK calculates the value of a feature flag for a given user, and returns a flag vlaue/an object that describes the way that the value was determined. ```python if client.initialize: user = {'key': user_key, 'name': user_name, 'age': age} + # evaluate the flag value flag_value = client.variation(flag_key, user, default_value) - # your if/else code according to flag value + # evaluate the flag value and get the detail + detail = client.variation_detail(flag_key, user, default=None) ``` -If evaluation called before SDK client initialized or you set the wrong flag key or user for the evaluation, SDK will return -the default value you set. The `fbclient.common_types.FlagState` will explain the details of the last evaluation including error raison. +If evaluation called before SDK client initialized or you set the wrong flag key or user for the evaluation, SDK will return the default value you set. The `fbclient.common_types.EvalDetail` will explain the details of the last evaluation including error raison. + +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. -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 ```python if client.initialize: user = {'key': user_key, 'name': user_name} all_flag_values = client.get_all_latest_flag_variations(user) - ed = all_flag_values.get(flag_key) - flag_value = ed.variation - # your if/else code according to flag value - + detail = all_flag_values.get(flag_key, default=None) ``` @@ -128,4 +176,14 @@ client.track_metric(user, event_name, numeric_value); **numeric_value** is not mandatory, the default value is **1**. 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. \ No newline at end of file +otherwise, the custom event may not be included into the experiment result. + +## Getting support + +- If you have a specific question about using this sdk, we encourage you + to [ask it in our slack](https://join.slack.com/t/featbit/shared_invite/zt-1ew5e2vbb-x6Apan1xZOaYMnFzqZkGNQ). +- If you encounter a bug or would like to request a + feature, [submit an issue](https://github.com/featbit/dotnet-server-sdk/issues/new). + +## See Also +- [Connect To Python Sdk](https://docs.featbit.co/docs/getting-started/4.-connect-an-sdk/server-side-sdks/python-sdk) \ No newline at end of file diff --git a/fbclient/common_types.py b/fbclient/common_types.py index 7718500..7bd15b2 100644 --- a/fbclient/common_types.py +++ b/fbclient/common_types.py @@ -215,7 +215,7 @@ def __init__(self, success: bool, reason: str, self._data = dict((ed.key_name, (ed, fb_event)) for ed, fb_event in data.items()) if data else {} self._event_handler = event_handler - def get(self, key_name: str, default: Any) -> EvalDetail: + def get(self, key_name: str, default: Any = None) -> EvalDetail: """Return the flag evaluation details of a given feature flag key This method will send event to back to feature flag center immediately diff --git a/fbclient/version.py b/fbclient/version.py index 50b5ea9..2a3eb2f 100644 --- a/fbclient/version.py +++ b/fbclient/version.py @@ -1 +1 @@ -VERSION = "1.0.1" +VERSION = "1.1.0" diff --git a/setup.py b/setup.py index 9c40d89..56d21d4 100644 --- a/setup.py +++ b/setup.py @@ -50,10 +50,11 @@ def parse_requirements(filename): 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', ], extras_require={ "dev": dev_reqs }, tests_require=dev_reqs, - python_requires='>=3.6, <=3.10' + python_requires='>=3.6, <=3.11' ) diff --git a/tests/test_fbclient.py b/tests/test_fbclient.py index d9ce279..78c88cb 100644 --- a/tests/test_fbclient.py +++ b/tests/test_fbclient.py @@ -213,8 +213,8 @@ 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 - detail = client.variation_detail("ff-test-bool", None, False) # type: ignore - assert detail.variation is False + detail = client.variation_detail("ff-test-bool", None, None) # type: ignore + assert detail.variation is None assert detail.reason == REASON_USER_NOT_SPECIFIED all_states = client.get_all_latest_flag_variations(None) # type: ignore assert all_states.success is False From f4733913a93cfc9df625efcf4a01c6fe47f3bd68 Mon Sep 17 00:00:00 2001 From: deleteLater Date: Thu, 13 Apr 2023 11:48:40 +0800 Subject: [PATCH 3/7] update README.md --- README.md | 100 ++++++++++++++++++++++++++---------------------------- 1 file changed, 48 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index cad8536..0b310e5 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,10 @@ -# FeatBit python sdk +# FeatBit Server-Side SDK for Python ## Introduction -This is the Python Server-Side SDK for the 100% open-source feature flags management -platform [FeatBit](https://github.com/featbit/featbit). It is intended for use in a multiple-users python server applications. +This is the Python Server-Side SDK for the 100% open-source feature flags management platform [FeatBit](https://github.com/featbit/featbit). -This SDK has two main purposes: - -- Store the available feature flags and evaluate the feature flags by given user in the server side SDK -- Sends feature flags usage, and custom events for the insights and A/B/n testing. +The FeatBit Server-Side SDK for Python is designed primarily for use in multi-user systems such as web servers and applications. ## Data synchonization @@ -17,7 +13,7 @@ default. Whenever there is any change to a feature flag or its related data, thi the average synchronization time is less than 100 ms. Be aware the websocket connection may be interrupted due to internet outage, but it will be resumed automatically once the problem is gone. -If you want to use your own data source, see [Offline](#offline). +If you want to use your own data source, see [Offline Mode](#offline-mode). ## Get Started @@ -28,7 +24,11 @@ install the sdk in using pip, this version of the SDK is compatible with Python pip install fb-python-sdk ``` -### Basic usage +### Quick Start + +> Note that the _**env_secret**_, _**streaming_url**_ and _**event_url**_ are required to initialize the SDK. + +The following code demonstrates basic usage of the SDK. ```python from fbclient import get, set_config @@ -43,17 +43,15 @@ client = get() if client.initialize: flag_key = '' - user_key = '' - user_name = '' + user_key = 'bot-id' + user_name = 'bot' user = {'key': user_key, 'name': user_name} detail = client.variation_detail(flag_key, user, default=None) - print(f'flag {flag_key} returns {detail.value} for user {user_key}') - print(f'Reason Description: {detail.reason}') + print(f'flag {flag_key} returns {detail.value} for user {user_key}, reason: {detail.reason}') -# should close the client when you don't need it anymore +# ensure that the SDK shuts down cleanly and has a chance to deliver events to FeatBit before the program exits client.stop() ``` -Note that the _**env_secret**_, _**streaming_url**_ and _**event_url**_ are required to initialize the SDK. ### Examples @@ -61,17 +59,17 @@ Note that the _**env_secret**_, _**streaming_url**_ and _**event_url**_ are requ ### FBClient -Applications SHOULD instantiate a single instance for the lifetime of the application. In the case where an application +Applications **SHOULD instantiate a single FBClient instance** for the lifetime of the application. In the case where an application needs to evaluate feature flags from different environments, you may create multiple clients, but they should still be retained for the lifetime of the application rather than created per request or per thread. -### Bootstrapping +#### Bootstrapping -The bootstrapping is in fact the call of constructor of `FFCClient`, in which the SDK will be initialized and connect to feature flag center +The bootstrapping is in fact the call of constructor of `FBClient`, in which the SDK will be initialized and connect to feature flag center. The constructor will return when it successfully connects, or when the timeout(default: 15 seconds) expires, whichever comes first. If it has not succeeded in connecting when the timeout elapses, you will receive the client in an uninitialized state where feature flags will return default values; it will still continue trying to connect in the background unless there has been a network error or you close the client(using `stop()`). You can detect whether initialization has succeeded by calling `initialize()`. -The best way to use the SDK as a singleton, first make sure you have called `fbclient.set_config()` at startup time. Then `fbclient.get()` will return the same shared `fbclient.client.FFCClient` instance each time. The client will be initialized if it runs first time. +The best way to use the SDK as a singleton, first make sure you have called `fbclient.set_config()` at startup time. Then `fbclient.get()` will return the same shared `fbclient.client.FBClient` instance each time. The client will be initialized if it runs first time. ```python from fbclient.config import Config from fbclient import get, set_config @@ -80,8 +78,7 @@ set_config(Config(env_secret, event_url, streaming_url)) client = get() if client.initialize: - # your code - + # the client is ready ``` You can also manage your `fbclient.client.FBClient`, the SDK will be initialized if you call `fbclient.client.FBClient` constructor. With constructor, you can set the timeout for initialization, the default value is 15 seconds. ```python @@ -91,8 +88,7 @@ from fbclient.client import FBClient client = FBClient(Config(env_secret, event_url, streaming_url), start_wait=15) if client.initialize: - # your code - + # the client is ready ``` If you prefer to have the constructor return immediately, and then wait for initialization to finish at some other point, you can use `fbclient.client.fbclient.update_status_provider` object, which provides an asynchronous way, as follows: @@ -100,37 +96,11 @@ If you prefer to have the constructor return immediately, and then wait for init from fbclient.config import Config from fbclient.client import FBClient -client = FFCClient(Config(env_secret), start_wait=0) +client = FBClient(Config(env_secret), start_wait=0) if client.update_status_provider.wait_for_OKState(): - # your code - -``` - -### Offline - -In the offline mode, SDK DOES not exchange any data with feature flag center, this mode is only use for internal test for instance. - -To open the offline mode: -```python -config = Config(env_secret, event_url, streaming_url, offline=True) -``` -When you put the SDK in offline mode, no insight message is sent to the server and all feature flag evaluations return -fallback values because there are no feature flags or segments available. If you want to use your own data source, -SDK allows users to populate feature flags and segments data from a JSON string. - -Here is an example: [fbclient_test_data.json](tests/fbclient_test_data.json). - -```shell -# replace http://localhost:5100 with your evaluation server url -curl -H "Authorization: " http://localhost:5100/api/public/sdk/server/latest-all > featbit-bootstrap.json + # the client is ready ``` -Then you can use this file to initialize the SDK in offline mode: - -```python -// first load data from file and then -client.initialize_from_external_json(json) -``` ### FBUser `User`: A dictionary of attributes that can affect flag evaluation, usually corresponding to a user of your application. @@ -166,6 +136,32 @@ if client.initialize: ``` +### Offline Mode + +In the offline mode, SDK DOES not exchange any data with feature flag center, this mode is only use for internal test for instance. + +To open the offline mode: +```python +config = Config(env_secret, event_url, streaming_url, offline=True) +``` +When you put the SDK in offline mode, no insight message is sent to the server and all feature flag evaluations return +fallback values because there are no feature flags or segments available. If you want to use your own data source, +SDK allows users to populate feature flags and segments data from a JSON string. + +Here is an example: [fbclient_test_data.json](tests/fbclient_test_data.json). + +```shell +# replace http://localhost:5100 with your evaluation server url +curl -H "Authorization: " http://localhost:5100/api/public/sdk/server/latest-all > featbit-bootstrap.json +``` + +Then you can use this file to initialize the SDK in offline mode: + +```python +// first load data from file and then +client.initialize_from_external_json(json) +``` + ### Experiments (A/B/n Testing) We support automatic experiments for pageviews and clicks, you just need to set your experiment on our SaaS platform, then you should be able to see the result in near real time after the experiment is started. @@ -183,7 +179,7 @@ otherwise, the custom event may not be included into the experiment result. - If you have a specific question about using this sdk, we encourage you to [ask it in our slack](https://join.slack.com/t/featbit/shared_invite/zt-1ew5e2vbb-x6Apan1xZOaYMnFzqZkGNQ). - If you encounter a bug or would like to request a - feature, [submit an issue](https://github.com/featbit/dotnet-server-sdk/issues/new). + feature, [submit an issue](https://github.com/featbit/featbit-python-sdk/issues/new). ## See Also - [Connect To Python Sdk](https://docs.featbit.co/docs/getting-started/4.-connect-an-sdk/server-side-sdks/python-sdk) \ No newline at end of file From 4699342bf2b01d30491da96ee4076952d64e17d0 Mon Sep 17 00:00:00 2001 From: deleteLater Date: Thu, 13 Apr 2023 13:19:19 +0800 Subject: [PATCH 4/7] update README.md --- README.md | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0b310e5..f9d3997 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ retained for the lifetime of the application rather than created per request or #### Bootstrapping -The bootstrapping is in fact the call of constructor of `FBClient`, in which the SDK will be initialized and connect to feature flag center. +The bootstrapping is in fact the call of constructor of `FBClient`, in which the SDK will be initialized and connect to FeatBit. The constructor will return when it successfully connects, or when the timeout(default: 15 seconds) expires, whichever comes first. If it has not succeeded in connecting when the timeout elapses, you will receive the client in an uninitialized state where feature flags will return default values; it will still continue trying to connect in the background unless there has been a network error or you close the client(using `stop()`). You can detect whether initialization has succeeded by calling `initialize()`. @@ -103,7 +103,7 @@ if client.update_status_provider.wait_for_OKState(): ### FBUser -`User`: A dictionary of attributes that can affect flag evaluation, usually corresponding to a user of your application. +A dictionary of attributes that can affect flag evaluation, usually corresponding to a user of your application. This object contains built-in properties(`key`, `name`). The `key` and `name` are required. The `key` must uniquely identify each user; this could be a username or email address for authenticated users, or a ID for anonymous users. The `name` is used to search your user quickly. You may also define custom properties with arbitrary names and values. For instance, the custom key should be a string; the custom value should be a string, number or boolean value @@ -122,8 +122,8 @@ 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) - ``` + If evaluation called before SDK client initialized or you set the wrong flag key or user for the evaluation, SDK will return the default value you set. The `fbclient.common_types.EvalDetail` will explain the details of the last evaluation including error raison. 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. @@ -133,22 +133,20 @@ if client.initialize: user = {'key': user_key, 'name': user_name} all_flag_values = client.get_all_latest_flag_variations(user) detail = all_flag_values.get(flag_key, default=None) - ``` ### Offline Mode -In the offline mode, SDK DOES not exchange any data with feature flag center, this mode is only use for internal test for instance. +In some situations, you might want to stop making remote calls to FeatBit. Here is how: -To open the offline mode: ```python config = Config(env_secret, event_url, streaming_url, offline=True) ``` When you put the SDK in offline mode, no insight message is sent to the server and all feature flag evaluations return fallback values because there are no feature flags or segments available. If you want to use your own data source, -SDK allows users to populate feature flags and segments data from a JSON string. +SDK allows users to populate feature flags and segments data from a JSON string. Here is an example: [fbclient_test_data.json](tests/fbclient_test_data.json). -Here is an example: [fbclient_test_data.json](tests/fbclient_test_data.json). +The format of the data in flags and segments is defined by FeatBit and is subject to change. Rather than trying to construct these objects yourself, it's simpler to request existing flags directly from the FeatBit server in JSON format and use this output as the starting point for your file. Here's how: ```shell # replace http://localhost:5100 with your evaluation server url From 851a734d73682f49434b010b6b08fa218f4ee08d Mon Sep 17 00:00:00 2001 From: deleteLater Date: Thu, 13 Apr 2023 13:34:35 +0800 Subject: [PATCH 5/7] nit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f9d3997..7fe0c5f 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ If you want to use your own data source, see [Offline Mode](#offline-mode). ### Installation install the sdk in using pip, this version of the SDK is compatible with Python 3.6 through 3.11. -``` +```shell pip install fb-python-sdk ``` From 690441654e05ef07ae453cf17e1dbc734bb1f7a5 Mon Sep 17 00:00:00 2001 From: ds Date: Sun, 16 Apr 2023 11:26:10 +0200 Subject: [PATCH 6/7] update README.md and bootstrapping --- README.md | 6 ++++-- fbclient/client.py | 8 ++++---- fbclient/streaming.py | 2 +- tests/test_fbclient.py | 8 +++++++- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7fe0c5f..cb64235 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,8 @@ if client.update_status_provider.wait_for_OKState(): # the client is ready ``` +> 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. + ### FBUser A dictionary of attributes that can affect flag evaluation, usually corresponding to a user of your application. @@ -124,8 +126,6 @@ if client.initialize: detail = client.variation_detail(flag_key, user, default=None) ``` -If evaluation called before SDK client initialized or you set the wrong flag key or user for the evaluation, SDK will return the default value you set. The `fbclient.common_types.EvalDetail` will explain the details of the last evaluation including error raison. - 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. ```python @@ -135,6 +135,8 @@ if client.initialize: detail = all_flag_values.get(flag_key, default=None) ``` +> Note that if evaluation called before Go SDK client initialized, you set the wrong flag key/user for the evaluation or the related feature flag is not found, SDK will return the default value you set. The `fbclient.common_types.EvalDetail` will explain the details of the latest evaluation including error raison. + ### Offline Mode In some situations, you might want to stop making remote calls to FeatBit. Here is how: diff --git a/fbclient/client.py b/fbclient/client.py index 5333244..225663f 100644 --- a/fbclient/client.py +++ b/fbclient/client.py @@ -90,8 +90,8 @@ def __init__(self, config: Config, start_wait: float = 15.): if not isinstance(self._update_processor, NullUpdateProcessor): log.info("FB Python SDK: Waiting for Client initialization in %s seconds" % str(start_wait)) - if isinstance(self._data_storage, NullDataStorage): - log.info("FB Python SDK: SDK just returns default variation") + if isinstance(self._data_storage, NullDataStorage) or not self._data_storage.initialized: + log.warning("FB Python SDK: SDK just returns default variation because of no data found in the given environment") update_processor_ready.wait(start_wait) if self._config.is_offline: @@ -179,7 +179,7 @@ def _evaluate_internal(self, key: str, user: dict, default: Any = None) -> _Eval default_value_type, default_value = self.__handle_default_value(key, default) try: if not self.initialize: - log.warning('FB Python SDK: Evaluation called before Java SDK client initialized for feature flag, well using the default value') + log.warning('FB Python SDK: Evaluation called before SDK is initialized for feature flag, well using the default value') return _EvalResult.error(default_value, REASON_CLIENT_NOT_READY, key, default_value_type) if not key: @@ -292,7 +292,7 @@ def is_flag_known(self, key: str) -> bool: """ try: if not self.initialize: - log.warning('FB Python SDK: isFlagKnown called before Java SDK client initialized for feature flag') + log.warning('FB Python SDK: isFlagKnown called before SDK is initialized for feature flag') return False return self._get_flag_internal(key) is not None except Exception as e: diff --git a/fbclient/streaming.py b/fbclient/streaming.py index d1da72c..b6056a7 100644 --- a/fbclient/streaming.py +++ b/fbclient/streaming.py @@ -228,4 +228,4 @@ def stop(self): @property def initialized(self) -> bool: - return self.__ready.is_set() and self.__storage.initialized + return self.__ready.is_set() diff --git a/tests/test_fbclient.py b/tests/test_fbclient.py index 78c88cb..d85bff8 100644 --- a/tests/test_fbclient.py +++ b/tests/test_fbclient.py @@ -1,6 +1,7 @@ import base64 from datetime import datetime from pathlib import Path +from time import sleep from unittest.mock import patch import pytest @@ -98,6 +99,8 @@ def start(): pass mock_start_method.side_effect = start with make_fb_client(NullUpdateProcessor, NullEventProcessor, start_wait=0) as client: + assert not client.initialize + sleep(0.1) assert not client.update_status_provider.wait_for_OKState(timeout=0.1) @@ -109,11 +112,14 @@ def start(): with make_fb_client(NullUpdateProcessor, NullEventProcessor, start_wait=0.1) as client: assert not client.initialize detail = client.variation_detail("ff-test-bool", USER_1, False) - assert not detail.variation + assert detail.variation is False assert detail.reason == REASON_CLIENT_NOT_READY 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 + detail = all_states.get("ff-test-bool", False) + assert detail.variation is False + assert detail.reason == REASON_FLAG_NOT_FOUND def test_bool_variation(): From 9f12f66690a8991f487c0661c755d6467c603e63 Mon Sep 17 00:00:00 2001 From: ds Date: Sun, 16 Apr 2023 11:31:25 +0200 Subject: [PATCH 7/7] update README.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index cb64235..df8cdc9 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,9 @@ if client.update_status_provider.wait_for_OKState(): # the client is ready ``` +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. + + > 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. ### FBUser